diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d4dae093..f5be9e94c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,12 @@ env: jobs: framework: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: test - run: dotnet test DigitalBrain.slnx -c Release + run: dotnet test DigitalBrain.slnx -c Release --verbosity minimal + timeout-minutes: 12 diff --git a/CLAUDE.md b/CLAUDE.md index 0ab2e9359..ad0660c25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,12 +65,13 @@ the live system. Tests are the regression net, not the proof. 3. Drive the real scenario through the real edge — HTTP to `digitalbrain-ui`, or the Flutter shell. 4. **Read the journal** via `digitalbrain-mcp`: `read_neuron_journal`, `read_chat_transcript`, `list_active_neurons`. Confirm the expected synapses fired, one correlation id, right order. -5. Cross-check Aspire — but only for what the table below says works. +5. Cross-check Aspire against structured logs and spans. 6. Root gate for regression. 7. Only now claim, quoting what you saw. Journals are the audit source; telemetry is a projection and never replaces them. Journals hold -causal facts only — never arguments, prompts or secrets. Telemetry tags follow the same rule. +causal facts only — never arguments, prompts or secrets. Production telemetry follows the same rule; +the product AppHost opts Development into prompt and response capture for local diagnosis. **What Aspire can actually tell you** (measured against a live AppHost after a real chat turn): @@ -78,20 +79,20 @@ causal facts only — never arguments, prompts or secrets. Telemetry tags follow |---|---| | `list_resources`, `list_console_logs` | works — console output is noisy with Azurite spam | | `digitalbrain-mcp` journals | works, and is authoritative | -| `list_structured_logs` | **empty** | -| `list_traces` | **no application spans** — `dotnet-cli` only | -| GenAI spans, metrics | **do not exist** | +| Structured logs | works — MCP invocation completion includes tool name and error state | +| Application traces | works — ASP.NET, Orleans and kernel spans carry causal identifiers | +| GenAI spans, metrics | works — provider, model, duration, token usage and finish reason; prompt and response content only when the AI module explicitly enables it | -No host calls `ConfigureOpenTelemetry`, no chat client calls `UseOpenTelemetry`, and the kernel's -`ActivitySource("DigitalBrain")` has no exporter. **This is the top open defect.** Until it lands, -step 5 confirms health and console output only — say that, rather than implying traces were checked. -Update this table when you fix it. +`dotnet test os/tests/DigitalBrain.OS.Product.Tests -c Release -- -explicit only` is the live oracle. +It starts and stops the product AppHost, drives a real Gemma4 turn and retry, confirms the durable +transcript and correlation, checks owner-scoped active-neuron discovery, and verifies GenAI usage +and Development message content in the exported span. ## Gates ```powershell dotnet build DigitalBrain.slnx -c Release -dotnet test DigitalBrain.slnx -c Release --logger "console;verbosity=minimal" +dotnet test DigitalBrain.slnx -c Release ``` **Never `--filter` for the completion gate** — a project-scoped run has already missed a failing diff --git a/DigitalBrain.slnx b/DigitalBrain.slnx index b1eb47727..5baffbf4a 100644 --- a/DigitalBrain.slnx +++ b/DigitalBrain.slnx @@ -12,6 +12,8 @@ + + @@ -54,6 +56,8 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4ff58e2..f8624bc60 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,8 +4,8 @@ - - + + @@ -24,8 +24,16 @@ + + + + + + + + @@ -37,4 +45,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 7a4e1fede..2f07a2356 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ await brain.SendAsync( ``` The owner-bound `IDigitalBrain` facade enters through a session; neurons call typed capabilities such -as `ILlama32` inside the brain. The same vocabulary will later carry approved C# behaviors generated +as `IGemma4` inside the brain. The same vocabulary will later carry approved C# behaviors generated from natural language. ## The shape of it @@ -22,7 +22,7 @@ from natural language. - **A synapse is a fact** — a thin record, broadcast, no reply. **An interface method is a request** — directed at a capability, and it replies. Both are journaled; neither is privileged. - **Modules own vocabulary** — synapse records and neuron interfaces, resolved at compile time. -- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.ILlama32` is identity, not +- **Namespaces and type names are architecture** — `DigitalBrain.AI.Ollama.IGemma4` is identity, not a lookup result. - **Journals are the audit source**, recording causal facts only — never arguments, prompts or secrets. Telemetry is a projection and never replaces them. @@ -37,11 +37,13 @@ The plan of record. Nothing is shipped unless it says Built. | Neuron/synapse foundation, owner-scoped client, module activation, AppHost composition, testing path | **Built** | | Typed AI, Tasks, Google, Salesforce, Chat, Flutter, Quickstart families | **Built** | | Flutter vertical — shell/scene vocabulary, UI HTTP/SSE edge, `WithUIEdge`/`WithFlutterHost`, headless Dart host, Windows chrome | **Built** | +| Product shell — responsive Chat, content-safe Activity, live 3D-projected Brain topology, pulses and inspector | **Built** | +| Product MCP surface — durable chat send/read, neuron journal observation, active-neuron discovery | **Built** | | Time — durable one-shot `ICountdown` and its recovery tests | **Built** | | Time — reminders, recurring interval/calendar scheduling, DST | Designed | -| Product chrome polish, multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed | +| Multi-principal IdP edge, journal observation on `IDigitalBrain` | Designed | | Behavior rail — proposal, approval, installation, execution, rollback | Designed | -| Observability spine — host OpenTelemetry, instrumented chat clients, GenAI spans | **Not built — top open defect** | +| Observability spine — host OpenTelemetry, structured logs, causal kernel spans, GenAI spans and metrics | **Built** | `DigitalBrain.Behaviors` is a packable SDK foundation (authoring interfaces, constrained context, manifests, artifact identities) and holds the canonical artifact codec. It is not a compiler, builder, @@ -70,6 +72,13 @@ git clean -fdx aspire run ``` +The explicit product suite performs a self-cleaning live Aspire proof across resource health, +Gemma4 chat, command retry, durable journals, owner-scoped introspection, and OpenTelemetry: + +```powershell +dotnet test os/tests/DigitalBrain.OS.Product.Tests -c Release -- -explicit only +``` + [CLAUDE.md](CLAUDE.md) is the working discipline for every agent and contributor: the gates, the verification ladder, and the traps. A green test suite is necessary, not sufficient — it proves the code holds, not that a behaviour works. diff --git a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart index 329a4a6de..97fb459e4 100644 --- a/clients/digitalbrain_flutter/bin/digitalbrain_host.dart +++ b/clients/digitalbrain_flutter/bin/digitalbrain_host.dart @@ -6,7 +6,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; Future main(List args) async { final shell = _arg(args, '--shell') ?? DigitalBrainHostEnv.resolveShell(); final open = _arg(args, '--open'); - final reconnectSeconds = int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2; + final reconnectSeconds = + int.tryParse(_arg(args, '--reconnect-seconds') ?? '') ?? 2; final client = DigitalBrainUiEdgeClient.fromEnvironment(); final surface = ShellSurfaceController(); diff --git a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart index eae8b0947..13c1e5aa7 100644 --- a/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart +++ b/clients/digitalbrain_flutter/lib/digitalbrain_flutter.dart @@ -1,12 +1,16 @@ -export 'package:digitalbrain_wire/digitalbrain_wire.dart' show - ActivateControlRequest, - ChatTurnEvent, - OpenSceneRequest, - SceneOpenedEvent, - SendMessageRequest; +export 'package:digitalbrain_wire/digitalbrain_wire.dart' + show + ActivateControlRequest, + BrainModule, + BrainNeuron, + BrainTopologySnapshot, + ChatTurnEvent, + OpenSceneRequest, + SceneOpenedEvent, + SendMessageRequest; export 'src/edge_client.dart'; export 'src/host_environment.dart'; export 'src/shell_surface.dart'; export 'src/sse_chat_frames.dart'; -export 'src/sse_frames.dart'; \ No newline at end of file +export 'src/sse_frames.dart'; diff --git a/clients/digitalbrain_flutter/lib/src/edge_client.dart b/clients/digitalbrain_flutter/lib/src/edge_client.dart index 4c36e21d7..d818dc6a2 100644 --- a/clients/digitalbrain_flutter/lib/src/edge_client.dart +++ b/clients/digitalbrain_flutter/lib/src/edge_client.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:digitalbrain_wire/digitalbrain_wire.dart'; @@ -8,11 +9,9 @@ import 'sse_chat_frames.dart'; import 'sse_frames.dart'; final class DigitalBrainUiEdgeClient { - DigitalBrainUiEdgeClient({ - required this.baseUri, - http.Client? httpClient, - }) : _http = httpClient ?? http.Client(), - _ownsClient = httpClient == null; + DigitalBrainUiEdgeClient({required this.baseUri, http.Client? httpClient}) + : _http = httpClient ?? http.Client(), + _ownsClient = httpClient == null; factory DigitalBrainUiEdgeClient.fromEnvironment({ http.Client? httpClient, @@ -148,6 +147,55 @@ final class DigitalBrainUiEdgeClient { } } + Future readBrainTopology({ + Duration requestTimeout = const Duration(seconds: 5), + }) async { + final uri = baseUri.replace(path: '/brain/topology'); + final abort = Completer(); + final operation = () async { + final request = http.AbortableRequest( + 'GET', + uri, + abortTrigger: abort.future, + ); + final streamed = await _http.send(request); + return http.Response.fromStream(streamed); + }(); + final response = await operation.timeout( + requestTimeout, + onTimeout: () { + abort.complete(); + throw http.RequestAbortedException(uri); + }, + ); + if (response.statusCode != 200) { + throw StateError( + 'brain-topology failed: ${response.statusCode} ${response.body}', + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('brain-topology response is not an object'); + } + + return BrainTopologySnapshot.fromJson(Map.from(decoded)); + } + + Stream watchBrainTopology({ + Duration pollInterval = const Duration(seconds: 2), + Duration requestTimeout = const Duration(seconds: 5), + }) async* { + while (true) { + try { + yield await readBrainTopology(requestTimeout: requestTimeout); + } on Object catch (error, stackTrace) { + yield* Stream.error(error, stackTrace); + } + await Future.delayed(pollInterval); + } + } + void close() { if (_ownsClient) { _http.close(); diff --git a/clients/digitalbrain_flutter/lib/src/shell_surface.dart b/clients/digitalbrain_flutter/lib/src/shell_surface.dart index 6b81ebb67..d1cacfa6d 100644 --- a/clients/digitalbrain_flutter/lib/src/shell_surface.dart +++ b/clients/digitalbrain_flutter/lib/src/shell_surface.dart @@ -26,7 +26,9 @@ final class ShellSurfaceController { title: event.title, sequence: event.sequence, ); - final index = _scenes.indexWhere((scene) => scene.sceneKey == view.sceneKey); + final index = _scenes.indexWhere( + (scene) => scene.sceneKey == view.sceneKey, + ); if (index >= 0) { _scenes[index] = view; } else { diff --git a/clients/digitalbrain_flutter/lib/src/sse_frames.dart b/clients/digitalbrain_flutter/lib/src/sse_frames.dart index 882737293..d1123940b 100644 --- a/clients/digitalbrain_flutter/lib/src/sse_frames.dart +++ b/clients/digitalbrain_flutter/lib/src/sse_frames.dart @@ -55,9 +55,7 @@ final class SseSceneOpenedParser { if (decoded is! Map) { return null; } - return SceneOpenedEvent.fromJson( - Map.from(decoded), - ); + return SceneOpenedEvent.fromJson(Map.from(decoded)); } on FormatException { return null; } on TypeError { diff --git a/clients/digitalbrain_flutter/shell/lib/activity_screen.dart b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart new file mode 100644 index 000000000..b13d0d860 --- /dev/null +++ b/clients/digitalbrain_flutter/shell/lib/activity_screen.dart @@ -0,0 +1,148 @@ +import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'brain_theme.dart'; + +final class ActivityScreen extends StatelessWidget { + const ActivityScreen({super.key, required this.turns}); + + final List turns; + + @override + Widget build(BuildContext context) { + return ColoredBox( + key: const Key('activity_screen'), + color: BrainPalette.surface, + child: turns.isEmpty + ? const _EmptyActivity() + : Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980), + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 32, + ), + children: [ + const _ActivityHeader(), + const SizedBox(height: 24), + for (final turn in turns.reversed) + _ActivityEntry(turn: turn), + ], + ), + ), + ), + ); + } +} + +final class _ActivityHeader extends StatelessWidget { + const _ActivityHeader(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Activity', style: BrainType.heading), + SizedBox(height: 8), + Text( + 'Durable conversation facts, newest first. Message content stays in Chat.', + style: BrainType.bodyMuted, + ), + ], + ); + } +} + +final class _EmptyActivity extends StatelessWidget { + const _EmptyActivity(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.timeline_rounded, + size: 34, + color: BrainPalette.textMuted, + ), + const SizedBox(height: 16), + const Text('No activity yet.', style: BrainType.empty), + const SizedBox(height: 7), + Text( + 'Journal facts appear after the conversation begins.', + style: BrainType.body.copyWith(color: BrainPalette.textMuted), + ), + ], + ), + ); + } +} + +final class _ActivityEntry extends StatelessWidget { + const _ActivityEntry({required this.turn}); + + final ChatTurnEvent turn; + + @override + Widget build(BuildContext context) { + final color = turn.fromUser ? BrainPalette.owner : BrainPalette.signal; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: BrainPalette.surfaceRaised, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + turn.fromUser + ? Icons.north_east_rounded + : Icons.south_west_rounded, + color: color, + size: 17, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + turn.synapse, + style: BrainType.metaStrong.copyWith(color: color), + ), + const SizedBox(height: 6), + Wrap( + spacing: 18, + runSpacing: 5, + children: [ + Text( + 'sequence ${turn.sequence.toString().padLeft(3, '0')}', + style: BrainType.meta, + ), + Text('command ${turn.commandId}', style: BrainType.meta), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/clients/digitalbrain_flutter/shell/lib/brain_screen.dart b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart new file mode 100644 index 000000000..4ba98b4c9 --- /dev/null +++ b/clients/digitalbrain_flutter/shell/lib/brain_screen.dart @@ -0,0 +1,434 @@ +import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'brain_theme.dart'; +import 'brain_topology_canvas.dart'; + +final class BrainScreen extends StatefulWidget { + const BrainScreen({ + super.key, + required this.chatName, + required this.turns, + this.topology, + this.statusMessage, + }); + + final String chatName; + final List turns; + final BrainTopologySnapshot? topology; + final String? statusMessage; + + @override + State createState() => _BrainScreenState(); +} + +final class _BrainScreenState extends State { + BrainTopologySelection? _selection; + + @override + void initState() { + super.initState(); + if (widget.turns.isNotEmpty) { + _selection = BrainPulseSelection(widget.turns.last); + } + } + + @override + void didUpdateWidget(covariant BrainScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.chatName != oldWidget.chatName) { + _selection = null; + return; + } + + if (widget.turns.isNotEmpty && + (oldWidget.turns.isEmpty || + widget.turns.last.sequence != oldWidget.turns.last.sequence || + widget.turns.last.correlationId != + oldWidget.turns.last.correlationId)) { + _selection = BrainPulseSelection(widget.turns.last); + return; + } + + final topology = widget.topology; + _selection = switch (_selection) { + BrainPulseSelection(:final turn) + when !widget.turns.any( + (candidate) => + candidate.sequence == turn.sequence && + candidate.correlationId == turn.correlationId, + ) => + null, + BrainNeuronSelection(:final neuron) + when topology == null || + !topology.neurons.any((candidate) => candidate.id == neuron.id) => + null, + BrainModuleSelection(:final module) + when topology == null || + !topology.modules.any((candidate) => candidate.id == module.id) => + null, + final selection => selection, + }; + } + + @override + Widget build(BuildContext context) { + final connected = + widget.statusMessage == null || widget.statusMessage!.isEmpty; + final lastSequence = widget.turns.isEmpty + ? '—' + : '${widget.turns.last.sequence}'; + final pulse = widget.turns.lastOrNull; + + return ColoredBox( + key: const Key('brain_screen'), + color: BrainPalette.surface, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1120), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + children: [ + const Text('Brain', style: BrainType.heading), + const SizedBox(height: 8), + const Text( + 'Live modules, owner-scoped neurons, and durable causal pulses.', + style: BrainType.bodyMuted, + ), + const SizedBox(height: 26), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _MetricCard( + label: 'Runtime', + value: connected ? 'Connected' : 'Offline', + accent: connected + ? BrainPalette.success + : BrainPalette.signal, + ), + _MetricCard( + label: 'Modules', + value: '${widget.topology?.modules.length ?? 0}', + ), + _MetricCard( + label: 'Active neurons', + value: '${widget.topology?.neurons.length ?? 0}', + ), + _MetricCard(label: 'Last sequence', value: lastSequence), + ], + ), + const SizedBox(height: 24), + _TopologyPanel( + topology: widget.topology, + pulse: pulse, + selection: _selection, + onSelected: (selection) => + setState(() => _selection = selection), + ), + if (!connected) ...[ + const SizedBox(height: 20), + _ConnectionNotice(message: widget.statusMessage!), + ], + ], + ), + ), + ), + ); + } +} + +final class _TopologyPanel extends StatelessWidget { + const _TopologyPanel({ + required this.topology, + required this.pulse, + required this.selection, + required this.onSelected, + }); + + final BrainTopologySnapshot? topology; + final ChatTurnEvent? pulse; + final BrainTopologySelection? selection; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final snapshot = topology; + if (snapshot == null) { + return Container( + height: 260, + decoration: _panelDecoration(), + child: const Center( + child: Text('Waiting for live topology…', style: BrainType.bodyMuted), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 860; + final canvas = Container( + height: 430, + decoration: _panelDecoration(), + clipBehavior: Clip.antiAlias, + child: BrainTopologyCanvas( + topology: snapshot, + pulse: pulse, + onSelected: onSelected, + ), + ); + final explorer = _TopologyExplorer( + topology: snapshot, + selection: selection, + onSelected: onSelected, + ); + + if (wide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: canvas), + const SizedBox(width: 14), + SizedBox(width: 320, child: explorer), + ], + ); + } + + return Column(children: [canvas, const SizedBox(height: 14), explorer]); + }, + ); + } +} + +final class _TopologyExplorer extends StatelessWidget { + const _TopologyExplorer({ + required this.topology, + required this.selection, + required this.onSelected, + }); + + final BrainTopologySnapshot topology; + final BrainTopologySelection? selection; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('brain_inspector'), + constraints: const BoxConstraints(minHeight: 430), + padding: const EdgeInsets.all(18), + decoration: _panelDecoration(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('INSPECTOR', style: BrainType.meta), + const SizedBox(height: 14), + _SelectionDetails(selection: selection), + const SizedBox(height: 20), + const Text('MODULES', style: BrainType.meta), + const SizedBox(height: 8), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + for (var index = 0; index < topology.modules.length; index++) + ActionChip( + key: Key('topology_module_$index'), + label: Text(brainModuleLabel(topology.modules[index])), + onPressed: () => + onSelected(BrainModuleSelection(topology.modules[index])), + ), + ], + ), + const SizedBox(height: 18), + const Text('ACTIVE NEURONS', style: BrainType.meta), + const SizedBox(height: 8), + for (var index = 0; index < topology.neurons.length; index++) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: TextButton( + key: Key('topology_neuron_$index'), + onPressed: () => + onSelected(BrainNeuronSelection(topology.neurons[index])), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + topology.neurons[index].id, + overflow: TextOverflow.ellipsis, + style: BrainType.meta, + ), + ), + ), + ), + ], + ), + ); + } +} + +final class _SelectionDetails extends StatelessWidget { + const _SelectionDetails({required this.selection}); + + final BrainTopologySelection? selection; + + @override + Widget build(BuildContext context) { + return switch (selection) { + BrainPulseSelection(:final turn) => _PulseDetails(turn: turn), + BrainNeuronSelection(:final neuron) => _NeuronDetails(neuron: neuron), + BrainModuleSelection(:final module) => _ModuleDetails(module: module), + null => const Text( + 'Select a module or neuron. New chat turns open their causal pulse automatically.', + style: BrainType.bodyMuted, + ), + }; + } +} + +final class _PulseDetails extends StatelessWidget { + const _PulseDetails({required this.turn}); + + final ChatTurnEvent turn; + + @override + Widget build(BuildContext context) { + return Column( + key: const Key('brain_pulse_details'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(turn.synapse, style: BrainType.cardTitle), + const SizedBox(height: 10), + _InspectorField(label: 'neuron', value: turn.neuronId), + _InspectorField(label: 'caller', value: turn.caller), + _InspectorField(label: 'correlation', value: turn.correlationId), + _InspectorField(label: 'command', value: turn.commandId), + _InspectorField(label: 'sequence', value: '${turn.sequence}'), + _InspectorField( + label: 'timestamp', + value: turn.timestamp.toIso8601String(), + ), + ], + ); + } +} + +final class _NeuronDetails extends StatelessWidget { + const _NeuronDetails({required this.neuron}); + + final BrainNeuron neuron; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(neuron.grainType, style: BrainType.cardTitle), + const SizedBox(height: 10), + _InspectorField(label: 'id', value: neuron.id), + _InspectorField(label: 'identity', value: neuron.identity), + _InspectorField(label: 'placement', value: neuron.placement), + ], + ); + } +} + +final class _ModuleDetails extends StatelessWidget { + const _ModuleDetails({required this.module}); + + final BrainModule module; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(brainModuleLabel(module), style: BrainType.cardTitle), + const SizedBox(height: 10), + _InspectorField(label: 'module id', value: module.id), + ], + ); + } +} + +final class _InspectorField extends StatelessWidget { + const _InspectorField({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: BrainType.meta), + const SizedBox(height: 2), + SelectableText(value, style: BrainType.metaStrong), + ], + ), + ); + } +} + +final class _MetricCard extends StatelessWidget { + const _MetricCard({ + required this.label, + required this.value, + this.accent = BrainPalette.textPrimary, + }); + + final String label; + final String value; + final Color accent; + + @override + Widget build(BuildContext context) { + return Container( + width: 190, + padding: const EdgeInsets.all(16), + decoration: _panelDecoration(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: BrainType.meta), + const SizedBox(height: 9), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: BrainType.metric.copyWith(color: accent), + ), + ], + ), + ); + } +} + +final class _ConnectionNotice extends StatelessWidget { + const _ConnectionNotice({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: BrainPalette.signal.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.25)), + ), + child: Text(message, style: BrainType.bodyMuted), + ); + } +} + +BoxDecoration _panelDecoration({Color color = BrainPalette.surfaceRaised}) => + BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.line), + ); diff --git a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart index afd811c23..4150cc939 100644 --- a/clients/digitalbrain_flutter/shell/lib/brain_theme.dart +++ b/clients/digitalbrain_flutter/shell/lib/brain_theme.dart @@ -1,24 +1,72 @@ import 'package:flutter/material.dart'; -/// The conversation is a journal you can talk to, so the surface reads as an -/// instrument rather than a messenger: deep slate-indigo, two voices separated -/// by colour temperature, and a monospace rail carrying real journal sequence. abstract final class BrainPalette { - static const surface = Color(0xFF14161D); - static const surfaceRaised = Color(0xFF1C1F29); - static const line = Color(0xFF2A2E3B); - static const textPrimary = Color(0xFFE6E8EF); - static const textMuted = Color(0xFF8A90A3); + static const navigation = Color(0xFF101219); + static const surface = Color(0xFF141720); + static const surfaceRaised = Color(0xFF1B1F2A); + static const surfaceSunken = Color(0xFF11141B); + static const line = Color(0xFF292E3B); + static const lineStrong = Color(0xFF363C4C); + static const textPrimary = Color(0xFFE9EBF2); + static const textMuted = Color(0xFF969CAF); + static const textFaint = Color(0xFF5E6474); + static const signal = Color(0xFFE09261); + static const owner = Color(0xFF7B9BE3); + static const success = Color(0xFF65C5A0); +} - /// The brain answering. - static const signal = Color(0xFFD98A5B); +abstract final class BrainTheme { + static ThemeData dark() { + final scheme = ColorScheme.fromSeed( + seedColor: BrainPalette.signal, + brightness: Brightness.dark, + surface: BrainPalette.surface, + ); - /// The owner speaking. - static const owner = Color(0xFF6E8FD4); + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: BrainPalette.surface, + colorScheme: scheme.copyWith( + primary: BrainPalette.signal, + secondary: BrainPalette.owner, + surface: BrainPalette.surface, + ), + dividerColor: BrainPalette.line, + navigationRailTheme: NavigationRailThemeData( + backgroundColor: BrainPalette.navigation, + indicatorColor: BrainPalette.signal.withValues(alpha: 0.14), + selectedIconTheme: const IconThemeData( + color: BrainPalette.signal, + size: 21, + ), + unselectedIconTheme: const IconThemeData( + color: BrainPalette.textMuted, + size: 20, + ), + selectedLabelTextStyle: BrainType.metaStrong.copyWith( + color: BrainPalette.signal, + ), + unselectedLabelTextStyle: BrainType.meta, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: BrainPalette.navigation, + indicatorColor: BrainPalette.signal.withValues(alpha: 0.14), + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? BrainType.metaStrong.copyWith(color: BrainPalette.signal) + : BrainType.meta, + ), + ), + tooltipTheme: const TooltipThemeData( + decoration: BoxDecoration(color: BrainPalette.surfaceRaised), + textStyle: BrainType.meta, + ), + ); + } } abstract final class BrainType { - /// Windows ships Cascadia; the fallbacks keep the rail monospace elsewhere. static const monoFamily = 'Cascadia Mono'; static const monoFallback = ['Consolas', 'Menlo', 'monospace']; @@ -34,6 +82,33 @@ abstract final class BrainType { color: BrainPalette.textPrimary, ); + static const heading = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.8, + color: BrainPalette.textPrimary, + ); + + static const cardTitle = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.15, + color: BrainPalette.textPrimary, + ); + + static const metric = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: BrainPalette.textPrimary, + ); + static const body = TextStyle( fontFamily: bodyFamily, fontFamilyFallback: bodyFallback, @@ -42,21 +117,38 @@ abstract final class BrainType { color: BrainPalette.textPrimary, ); + static const bodyMuted = TextStyle( + fontFamily: bodyFamily, + fontFamilyFallback: bodyFallback, + fontSize: 14, + height: 1.5, + color: BrainPalette.textMuted, + ); + static const meta = TextStyle( fontFamily: monoFamily, fontFamilyFallback: monoFallback, fontSize: 11, fontWeight: FontWeight.w500, - letterSpacing: 0.5, + letterSpacing: 0.45, color: BrainPalette.textMuted, ); + static const metaStrong = TextStyle( + fontFamily: monoFamily, + fontFamilyFallback: monoFallback, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.45, + color: BrainPalette.textPrimary, + ); + static const empty = TextStyle( fontFamily: bodyFamily, fontFamilyFallback: bodyFallback, - fontSize: 17, - fontWeight: FontWeight.w500, - letterSpacing: -0.2, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, color: BrainPalette.textPrimary, ); } diff --git a/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart new file mode 100644 index 000000000..d08694075 --- /dev/null +++ b/clients/digitalbrain_flutter/shell/lib/brain_topology_canvas.dart @@ -0,0 +1,433 @@ +import 'dart:math' as math; + +import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; +import 'package:flutter/material.dart'; + +import 'brain_theme.dart'; + +sealed class BrainTopologySelection { + const BrainTopologySelection(); +} + +final class BrainModuleSelection extends BrainTopologySelection { + const BrainModuleSelection(this.module); + + final BrainModule module; +} + +final class BrainNeuronSelection extends BrainTopologySelection { + const BrainNeuronSelection(this.neuron); + + final BrainNeuron neuron; +} + +final class BrainPulseSelection extends BrainTopologySelection { + const BrainPulseSelection(this.turn); + + final ChatTurnEvent turn; +} + +final class BrainTopologyCanvas extends StatefulWidget { + const BrainTopologyCanvas({ + super.key, + required this.topology, + required this.onSelected, + this.pulse, + }); + + final BrainTopologySnapshot topology; + final ChatTurnEvent? pulse; + final ValueChanged onSelected; + + @override + State createState() => _BrainTopologyCanvasState(); +} + +final class _BrainTopologyCanvasState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulse; + double _rotationX = -0.18; + double _rotationY = 0.42; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + if (_hasPulseTarget(widget.topology, widget.pulse)) { + _pulse.forward(); + } + } + + @override + void didUpdateWidget(covariant BrainTopologyCanvas oldWidget) { + super.didUpdateWidget(oldWidget); + final pulseChanged = + widget.pulse?.sequence != oldWidget.pulse?.sequence || + widget.pulse?.correlationId != oldWidget.pulse?.correlationId; + final hadTarget = _hasPulseTarget(oldWidget.topology, oldWidget.pulse); + final hasTarget = _hasPulseTarget(widget.topology, widget.pulse); + if (!hasTarget) { + if (pulseChanged || hadTarget) { + _pulse.reset(); + } + } else if (pulseChanged || !hadTarget) { + _pulse.forward(from: 0); + } + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + void _rotate(DragUpdateDetails details) { + setState(() { + _rotationY += details.delta.dx * 0.008; + _rotationX = (_rotationX + details.delta.dy * 0.008).clamp(-1.0, 1.0); + }); + } + + @override + Widget build(BuildContext context) { + final disableAnimations = MediaQuery.disableAnimationsOf(context); + final hasPulseTarget = _hasPulseTarget(widget.topology, widget.pulse); + final localPulse = + hasPulseTarget && widget.pulse!.caller == widget.pulse!.neuronId; + final edgePulse = + hasPulseTarget && + !localPulse && + _hasNode(widget.topology, widget.pulse!.caller); + + return Semantics( + key: const Key('brain_topology_canvas'), + label: + 'Interactive three-dimensional DigitalBrain topology. Drag to rotate; use the topology list to inspect accessible node details.', + image: true, + child: LayoutBuilder( + builder: (context, constraints) { + final size = Size(constraints.maxWidth, constraints.maxHeight); + return AnimatedBuilder( + animation: _pulse, + builder: (context, _) { + final projected = _projectTopology( + widget.topology, + size, + _rotationX, + _rotationY, + ); + final pulseValue = disableAnimations ? 1.0 : _pulse.value; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: _rotate, + onTapUp: (details) { + final selected = _hitTest(projected, details.localPosition); + if (selected != null) { + widget.onSelected(selected.selection); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: _TopologyPainter( + nodes: projected, + pulse: widget.pulse, + pulseValue: pulseValue, + ), + ), + if (hasPulseTarget) + const IgnorePointer(key: Key('brain_pulse')), + if (localPulse) + const IgnorePointer(key: Key('brain_local_pulse')), + if (edgePulse) + const IgnorePointer(key: Key('brain_edge_pulse')), + ], + ), + ); + }, + ); + }, + ), + ); + } +} + +bool _hasPulseTarget(BrainTopologySnapshot topology, ChatTurnEvent? pulse) => + pulse != null && + topology.neurons.any((neuron) => neuron.id == pulse.neuronId); + +bool _hasNode(BrainTopologySnapshot topology, String id) => + topology.neurons.any((neuron) => neuron.id == id) || + topology.modules.any((module) => module.id == id); + +final class _GraphNode { + const _GraphNode({ + required this.id, + required this.label, + required this.module, + required this.selection, + required this.x, + required this.y, + required this.z, + }); + + final String id; + final String label; + final bool module; + final BrainTopologySelection selection; + final double x; + final double y; + final double z; +} + +final class _ProjectedNode { + const _ProjectedNode({ + required this.node, + required this.center, + required this.radius, + required this.depth, + }); + + final _GraphNode node; + final Offset center; + final double radius; + final double depth; + + BrainTopologySelection get selection => node.selection; +} + +List<_ProjectedNode> _projectTopology( + BrainTopologySnapshot topology, + Size size, + double rotationX, + double rotationY, +) { + final graph = <_GraphNode>[ + ..._placeModules(topology.modules), + ..._placeNeurons(topology.neurons), + ]; + final base = math.min(size.width, size.height) * 0.36; + final center = Offset(size.width * 0.5, size.height * 0.51); + final cosY = math.cos(rotationY); + final sinY = math.sin(rotationY); + final cosX = math.cos(rotationX); + final sinX = math.sin(rotationX); + + final projected = <_ProjectedNode>[]; + for (final node in graph) { + final xY = node.x * cosY + node.z * sinY; + final zY = -node.x * sinY + node.z * cosY; + final yX = node.y * cosX - zY * sinX; + final zX = node.y * sinX + zY * cosX; + final perspective = 1.0 / (1.85 - zX * 0.36); + final radius = (node.module ? 10.0 : 6.0) * (0.72 + perspective); + + projected.add( + _ProjectedNode( + node: node, + center: Offset( + center.dx + xY * base * perspective, + center.dy + yX * base * perspective, + ), + radius: radius, + depth: zX, + ), + ); + } + + projected.sort((a, b) => a.depth.compareTo(b.depth)); + return projected; +} + +Iterable<_GraphNode> _placeModules(List modules) sync* { + for (var index = 0; index < modules.length; index++) { + final position = _spherePosition(index, modules.length, 0.88, 0); + final module = modules[index]; + yield _GraphNode( + id: module.id, + label: brainModuleLabel(module), + module: true, + selection: BrainModuleSelection(module), + x: position.x, + y: position.y, + z: position.z, + ); + } +} + +Iterable<_GraphNode> _placeNeurons(List neurons) sync* { + for (var index = 0; index < neurons.length; index++) { + final position = _spherePosition(index, neurons.length, 0.62, 1.3); + final neuron = neurons[index]; + yield _GraphNode( + id: neuron.id, + label: neuron.grainType, + module: false, + selection: BrainNeuronSelection(neuron), + x: position.x, + y: position.y, + z: position.z, + ); + } +} + +({double x, double y, double z}) _spherePosition( + int index, + int count, + double radius, + double phase, +) { + if (count <= 1) { + return (x: 0, y: 0, z: radius); + } + final y = 1 - (2 * (index + 0.5) / count); + final ring = math.sqrt(math.max(0, 1 - y * y)); + final theta = index * math.pi * (3 - math.sqrt(5)) + phase; + return ( + x: math.cos(theta) * ring * radius, + y: y * radius, + z: math.sin(theta) * ring * radius, + ); +} + +_ProjectedNode? _hitTest(List<_ProjectedNode> nodes, Offset position) { + for (final node in nodes.reversed) { + if ((node.center - position).distance <= node.radius + 8) { + return node; + } + } + return null; +} + +String brainModuleLabel(BrainModule module) { + final type = module.id.split('.').last; + return type.endsWith('Module') + ? type.substring(0, type.length - 'Module'.length) + : type; +} + +final class _TopologyPainter extends CustomPainter { + const _TopologyPainter({ + required this.nodes, + required this.pulse, + required this.pulseValue, + }); + + final List<_ProjectedNode> nodes; + final ChatTurnEvent? pulse; + final double pulseValue; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width * 0.5, size.height * 0.51); + final hull = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = BrainPalette.lineStrong.withValues(alpha: 0.55); + final radius = math.min(size.width, size.height) * 0.34; + canvas.drawOval( + Rect.fromCenter( + center: center, + width: radius * 2.15, + height: radius * 1.55, + ), + hull, + ); + canvas.drawOval( + Rect.fromCenter( + center: center, + width: radius * 1.35, + height: radius * 2.05, + ), + hull..color = BrainPalette.line.withValues(alpha: 0.5), + ); + + final pulseTarget = pulse == null + ? null + : nodes.where((node) => node.node.id == pulse!.neuronId).firstOrNull; + final pulseSource = pulse == null + ? null + : nodes.where((node) => node.node.id == pulse!.caller).firstOrNull; + if (pulseTarget != null) { + final wave = math.sin(pulseValue * math.pi).abs(); + if (pulseSource != null && pulseSource.node.id != pulseTarget.node.id) { + final path = Path() + ..moveTo(pulseSource.center.dx, pulseSource.center.dy) + ..quadraticBezierTo( + center.dx, + center.dy - radius * 0.45, + pulseTarget.center.dx, + pulseTarget.center.dy, + ); + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..color = BrainPalette.signal.withValues(alpha: 0.25 + wave * 0.7), + ); + } + canvas.drawCircle( + pulseTarget.center, + pulseTarget.radius + 8 + wave * 16, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = BrainPalette.signal.withValues(alpha: 0.7 - wave * 0.3), + ); + } + + for (final projected in nodes) { + final node = projected.node; + final color = node.module ? BrainPalette.signal : BrainPalette.owner; + final depthAlpha = (0.5 + (projected.depth + 1) * 0.22).clamp(0.35, 1.0); + canvas.drawCircle( + projected.center, + projected.radius * 1.8, + Paint() + ..color = color.withValues(alpha: 0.06 * depthAlpha) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), + ); + canvas.drawCircle( + projected.center, + projected.radius, + Paint()..color = color.withValues(alpha: depthAlpha), + ); + canvas.drawCircle( + projected.center, + projected.radius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = BrainPalette.textPrimary.withValues(alpha: 0.3), + ); + + if (node.module || node.id == pulse?.neuronId) { + final text = TextPainter( + text: TextSpan( + text: node.label, + style: BrainType.meta.copyWith( + color: BrainPalette.textPrimary.withValues(alpha: depthAlpha), + ), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: 120); + text.paint( + canvas, + projected.center + Offset(-text.width / 2, projected.radius + 7), + ); + } + } + } + + @override + bool shouldRepaint(covariant _TopologyPainter oldDelegate) => + oldDelegate.nodes != nodes || + oldDelegate.pulse != pulse || + oldDelegate.pulseValue != pulseValue; +} diff --git a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart index 0fa71573c..bb1cd9c3c 100644 --- a/clients/digitalbrain_flutter/shell/lib/chat_screen.dart +++ b/clients/digitalbrain_flutter/shell/lib/chat_screen.dart @@ -4,6 +4,8 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'activity_screen.dart'; +import 'brain_screen.dart'; import 'brain_theme.dart'; typedef SendMessage = Future Function(String text); @@ -13,12 +15,14 @@ final class BrainChatApp extends StatelessWidget { super.key, required this.chatName, this.turns, + this.topology, this.onSend, this.statusMessage, }); final String chatName; final Stream? turns; + final Stream? topology; final SendMessage? onSend; final String? statusMessage; @@ -27,19 +31,11 @@ final class BrainChatApp extends StatelessWidget { return MaterialApp( title: 'DigitalBrain', debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - scaffoldBackgroundColor: BrainPalette.surface, - colorScheme: ColorScheme.fromSeed( - seedColor: BrainPalette.signal, - brightness: Brightness.dark, - surface: BrainPalette.surface, - ), - ), - home: BrainChatScreen( + theme: BrainTheme.dark(), + home: _BrainWorkspace( chatName: chatName, turns: turns, + topology: topology, onSend: onSend, statusMessage: statusMessage, ), @@ -47,64 +43,374 @@ final class BrainChatApp extends StatelessWidget { } } -final class BrainChatScreen extends StatefulWidget { - const BrainChatScreen({ - super.key, +final class _BrainWorkspace extends StatefulWidget { + const _BrainWorkspace({ required this.chatName, this.turns, + this.topology, this.onSend, this.statusMessage, }); final String chatName; final Stream? turns; + final Stream? topology; final SendMessage? onSend; final String? statusMessage; @override - State createState() => _BrainChatScreenState(); + State<_BrainWorkspace> createState() => _BrainWorkspaceState(); } -final class _BrainChatScreenState extends State { +final class _BrainWorkspaceState extends State<_BrainWorkspace> { + static const _compactBreakpoint = 720.0; + final _turns = []; final _seen = {}; - final _composer = TextEditingController(); - final _composerFocus = FocusNode(); - final _scroll = ScrollController(); - StreamSubscription? _subscription; - bool _awaitingBrain = false; - String? _failure; + StreamSubscription? _topologySubscription; + BrainTopologySnapshot? _topology; + int _destination = 0; + String? _turnFailure; + String? _topologyFailure; @override void initState() { super.initState(); _listen(widget.turns); + _listenTopology(widget.topology); } @override - void didUpdateWidget(covariant BrainChatScreen oldWidget) { + void didUpdateWidget(covariant _BrainWorkspace oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.chatName != widget.chatName) { + _turns.clear(); + _seen.clear(); + } if (!identical(oldWidget.turns, widget.turns)) { - _subscription?.cancel(); + unawaited(_subscription?.cancel()); _listen(widget.turns); } + if (!identical(oldWidget.topology, widget.topology)) { + unawaited(_topologySubscription?.cancel()); + _listenTopology(widget.topology); + } + } + + void _listenTopology(Stream? topology) { + _topologySubscription = topology?.listen( + (snapshot) { + if (mounted) { + setState(() { + _topology = snapshot; + _topologyFailure = null; + }); + } + }, + onError: (Object error) { + if (mounted) { + setState(() { + _topology = null; + _topologyFailure = '$error'; + }); + } + }, + ); } void _listen(Stream? turns) { - _subscription = turns?.listen((turn) { - if (!mounted || !_seen.add(turn.sequence)) { - return; - } - setState(() { - _turns.add(turn); - _turns.sort((a, b) => a.sequence.compareTo(b.sequence)); - if (!turn.fromUser) { - _awaitingBrain = false; + _turnFailure = null; + _subscription = turns?.listen( + (turn) { + if (!mounted || !_seen.add(turn.sequence)) { + return; } - }); + setState(() { + _turns.add(turn); + _turns.sort((a, b) => a.sequence.compareTo(b.sequence)); + }); + }, + onError: (Object error) { + if (mounted) { + setState(() => _turnFailure = '$error'); + } + }, + ); + } + + String? get _statusMessage => + widget.statusMessage ?? _turnFailure ?? _topologyFailure; + + void _selectDestination(int index) { + if (_destination != index) { + setState(() => _destination = index); + } + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + unawaited(_topologySubscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final projectedTurns = List.unmodifiable(_turns); + final pages = [ + BrainChatScreen( + chatName: widget.chatName, + turns: projectedTurns, + onSend: widget.onSend, + ), + ActivityScreen(turns: projectedTurns), + BrainScreen( + chatName: widget.chatName, + turns: projectedTurns, + topology: _topology, + statusMessage: _statusMessage, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < _compactBreakpoint; + final content = Column( + children: [ + _StatusBar( + chatName: widget.chatName, + section: _sectionName(_destination), + message: _statusMessage, + ), + Expanded( + child: IndexedStack(index: _destination, children: pages), + ), + ], + ); + + return Scaffold( + body: compact + ? content + : Row( + children: [ + _WorkspaceRail( + selectedIndex: _destination, + onSelected: _selectDestination, + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded(child: content), + ], + ), + bottomNavigationBar: compact + ? _WorkspaceNavigationBar( + selectedIndex: _destination, + onSelected: _selectDestination, + ) + : null, + ); + }, + ); + } +} + +String _sectionName(int index) => switch (index) { + 0 => 'Chat', + 1 => 'Activity', + _ => 'Brain', +}; + +final class _WorkspaceRail extends StatelessWidget { + const _WorkspaceRail({required this.selectedIndex, required this.onSelected}); + + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return NavigationRail( + backgroundColor: BrainPalette.navigation, + minWidth: 88, + groupAlignment: -0.78, + labelType: NavigationRailLabelType.all, + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + leading: const Padding( + padding: EdgeInsets.only(top: 10, bottom: 28), + child: _BrainMark(), + ), + destinations: _destinations(), + ); + } +} + +final class _WorkspaceNavigationBar extends StatelessWidget { + const _WorkspaceNavigationBar({ + required this.selectedIndex, + required this.onSelected, + }); + + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.forum_outlined, key: Key('destination_chat')), + selectedIcon: Icon(Icons.forum, key: Key('destination_chat')), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')), + selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')), + label: 'Activity', + ), + NavigationDestination( + icon: Icon(Icons.hub_outlined, key: Key('destination_brain')), + selectedIcon: Icon(Icons.hub, key: Key('destination_brain')), + label: 'Brain', + ), + ], + ); + } +} + +List _destinations() => const [ + NavigationRailDestination( + icon: Icon(Icons.forum_outlined, key: Key('destination_chat')), + selectedIcon: Icon(Icons.forum, key: Key('destination_chat')), + label: Text('Chat'), + ), + NavigationRailDestination( + icon: Icon(Icons.timeline_outlined, key: Key('destination_activity')), + selectedIcon: Icon(Icons.timeline, key: Key('destination_activity')), + label: Text('Activity'), + ), + NavigationRailDestination( + icon: Icon(Icons.hub_outlined, key: Key('destination_brain')), + selectedIcon: Icon(Icons.hub, key: Key('destination_brain')), + label: Text('Brain'), + ), +]; + +final class _BrainMark extends StatelessWidget { + const _BrainMark(); + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: BrainPalette.signal.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: BrainPalette.signal.withValues(alpha: 0.4)), + ), + child: const Icon( + Icons.graphic_eq_rounded, + color: BrainPalette.signal, + size: 20, + ), + ); + } +} + +final class _StatusBar extends StatelessWidget { + const _StatusBar({ + required this.chatName, + required this.section, + this.message, + }); + + final String chatName; + final String section; + final String? message; + + @override + Widget build(BuildContext context) { + final offline = message != null && message!.isNotEmpty; + + return Container( + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: const BoxDecoration( + color: BrainPalette.surfaceRaised, + border: Border(bottom: BorderSide(color: BrainPalette.line)), + ), + child: Row( + children: [ + const Text('DigitalBrain', style: BrainType.title), + const SizedBox(width: 10), + Container(width: 1, height: 16, color: BrainPalette.lineStrong), + const SizedBox(width: 10), + Text(section, style: BrainType.metaStrong), + const Spacer(), + Text('chat:$chatName', style: BrainType.meta), + const SizedBox(width: 14), + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: offline ? BrainPalette.signal : BrainPalette.success, + boxShadow: [ + BoxShadow( + color: (offline ? BrainPalette.signal : BrainPalette.success) + .withValues(alpha: 0.35), + blurRadius: 8, + ), + ], + ), + ), + const SizedBox(width: 8), + Text(offline ? 'not connected' : 'connected', style: BrainType.meta), + ], + ), + ); + } +} + +final class BrainChatScreen extends StatefulWidget { + const BrainChatScreen({ + super.key, + required this.chatName, + required this.turns, + this.onSend, + }); + + final String chatName; + final List turns; + final SendMessage? onSend; + + @override + State createState() => _BrainChatScreenState(); +} + +final class _BrainChatScreenState extends State { + final _composer = TextEditingController(); + final _composerFocus = FocusNode(); + final _scroll = ScrollController(); + + bool _awaitingBrain = false; + int _awaitingAfterSequence = 0; + String? _failure; + + @override + void didUpdateWidget(covariant BrainChatScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.turns.length != oldWidget.turns.length) { + if (_awaitingBrain && + widget.turns.any( + (turn) => !turn.fromUser && turn.sequence > _awaitingAfterSequence, + )) { + setState(() => _awaitingBrain = false); + } _scrollToLatest(); - }); + } } void _scrollToLatest() { @@ -130,6 +436,9 @@ final class _BrainChatScreenState extends State { _composer.clear(); setState(() { _awaitingBrain = true; + _awaitingAfterSequence = widget.turns.isEmpty + ? 0 + : widget.turns.last.sequence; _failure = null; }); @@ -148,7 +457,6 @@ final class _BrainChatScreenState extends State { @override void dispose() { - _subscription?.cancel(); _composer.dispose(); _composerFocus.dispose(); _scroll.dispose(); @@ -157,15 +465,15 @@ final class _BrainChatScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: Column( + return ColoredBox( + color: BrainPalette.surface, + child: Column( children: [ - _StatusBar(chatName: widget.chatName, message: widget.statusMessage), Expanded( - child: _turns.isEmpty && !_awaitingBrain + child: widget.turns.isEmpty && !_awaitingBrain ? const _EmptyJournal() : _Journal( - turns: _turns, + turns: widget.turns, awaitingBrain: _awaitingBrain, controller: _scroll, ), @@ -183,45 +491,6 @@ final class _BrainChatScreenState extends State { } } -final class _StatusBar extends StatelessWidget { - const _StatusBar({required this.chatName, this.message}); - - final String chatName; - final String? message; - - @override - Widget build(BuildContext context) { - final offline = message != null && message!.isNotEmpty; - - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 20), - decoration: const BoxDecoration( - color: BrainPalette.surfaceRaised, - border: Border(bottom: BorderSide(color: BrainPalette.line)), - ), - child: Row( - children: [ - const Text('DigitalBrain', style: BrainType.title), - const SizedBox(width: 12), - Text('chat:$chatName', style: BrainType.meta), - const Spacer(), - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: offline ? BrainPalette.signal : BrainPalette.owner, - ), - ), - const SizedBox(width: 8), - Text(offline ? 'not connected' : 'connected', style: BrainType.meta), - ], - ), - ); - } -} - final class _EmptyJournal extends StatelessWidget { const _EmptyJournal(); @@ -231,8 +500,22 @@ final class _EmptyJournal extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: BrainPalette.surfaceRaised, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: BrainPalette.line), + ), + child: const Icon( + Icons.chat_bubble_outline_rounded, + color: BrainPalette.textMuted, + ), + ), + const SizedBox(height: 18), const Text('Nothing yet.', style: BrainType.empty), - const SizedBox(height: 8), + const SizedBox(height: 7), Text( 'Ask your brain to do something.', style: BrainType.body.copyWith(color: BrainPalette.textMuted), @@ -243,8 +526,6 @@ final class _EmptyJournal extends StatelessWidget { } } -/// The signature: every turn keeps its real journal sequence in a monospace -/// rail. Order here is durable kernel truth, not scroll position. final class _Journal extends StatelessWidget { const _Journal({ required this.turns, @@ -261,11 +542,11 @@ final class _Journal extends StatelessWidget { return Align( alignment: Alignment.topCenter, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), + constraints: const BoxConstraints(maxWidth: 820), child: ListView.builder( key: const Key('chat_journal'), controller: controller, - padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 34, horizontal: 28), itemCount: turns.length + (awaitingBrain ? 1 : 0), itemBuilder: (context, index) { if (index == turns.length) { @@ -290,12 +571,12 @@ final class _JournalTurn extends StatelessWidget { return Padding( key: Key('turn_${turn.sequence}'), - padding: const EdgeInsets.only(bottom: 26), + padding: const EdgeInsets.only(bottom: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 44, + width: 48, child: Padding( padding: const EdgeInsets.only(top: 3), child: Text( @@ -304,17 +585,24 @@ final class _JournalTurn extends StatelessWidget { ), ), ), - Container(width: 2, height: 20, color: voice), - const SizedBox(width: 16), + Container( + width: 3, + height: 22, + decoration: BoxDecoration( + color: voice, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( turn.fromUser ? 'you' : 'brain', - style: BrainType.meta.copyWith(color: voice), + style: BrainType.metaStrong.copyWith(color: voice), ), - const SizedBox(height: 6), + const SizedBox(height: 7), SelectableText(turn.text, style: BrainType.body), ], ), @@ -331,16 +619,23 @@ final class _AwaitingTurn extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: 26), + padding: const EdgeInsets.only(bottom: 28), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(width: 44), - Container(width: 2, height: 20, color: BrainPalette.signal), - const SizedBox(width: 16), + const SizedBox(width: 48), + Container( + width: 3, + height: 22, + decoration: BoxDecoration( + color: BrainPalette.signal, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 18), Text( 'thinking', - style: BrainType.meta.copyWith(color: BrainPalette.signal), + style: BrainType.metaStrong.copyWith(color: BrainPalette.signal), ), ], ), @@ -357,8 +652,8 @@ final class _FailureNotice extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - color: BrainPalette.surfaceRaised, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 11), + color: BrainPalette.signal.withValues(alpha: 0.08), child: Text( message, style: BrainType.meta.copyWith(color: BrainPalette.signal), @@ -387,61 +682,72 @@ final class _Composer extends StatelessWidget { color: BrainPalette.surfaceRaised, border: Border(top: BorderSide(color: BrainPalette.line)), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Shortcuts( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.enter): _SubmitIntent(), - }, - child: Actions( - actions: { - _SubmitIntent: CallbackAction<_SubmitIntent>( - onInvoke: (_) { - unawaited(onSubmit()); - return null; - }, - ), + constraints: const BoxConstraints(maxWidth: 820), + child: Container( + decoration: BoxDecoration( + color: BrainPalette.surfaceSunken, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: BrainPalette.lineStrong), + ), + padding: const EdgeInsets.only(left: 16, right: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): + _SubmitIntent(), }, - child: TextField( - key: const Key('chat_composer'), - controller: controller, - focusNode: focusNode, - enabled: enabled, - autofocus: true, - maxLines: 4, - minLines: 1, - style: BrainType.body, - cursorColor: BrainPalette.signal, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - hintText: 'Ask your brain…', - hintStyle: BrainType.body - .copyWith(color: BrainPalette.textMuted), + child: Actions( + actions: { + _SubmitIntent: CallbackAction<_SubmitIntent>( + onInvoke: (_) { + unawaited(onSubmit()); + return null; + }, + ), + }, + child: TextField( + key: const Key('chat_composer'), + controller: controller, + focusNode: focusNode, + enabled: enabled, + autofocus: true, + maxLines: 4, + minLines: 1, + style: BrainType.body, + cursorColor: BrainPalette.signal, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 13, + ), + hintText: 'Ask your brain…', + hintStyle: BrainType.body.copyWith( + color: BrainPalette.textMuted, + ), + ), ), ), ), ), - ), - const SizedBox(width: 12), - TextButton( - key: const Key('chat_send'), - onPressed: enabled ? () => unawaited(onSubmit()) : null, - style: TextButton.styleFrom( - foregroundColor: BrainPalette.signal, - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + const SizedBox(width: 8), + IconButton( + key: const Key('chat_send'), + onPressed: enabled ? () => unawaited(onSubmit()) : null, + tooltip: 'Send', + color: BrainPalette.signal, + disabledColor: BrainPalette.textFaint, + icon: const Icon(Icons.arrow_upward_rounded), ), - child: const Text('Send', style: BrainType.meta), - ), - ], + ], + ), ), ), ), diff --git a/clients/digitalbrain_flutter/shell/lib/main.dart b/clients/digitalbrain_flutter/shell/lib/main.dart index c95e2f64d..c6f167808 100644 --- a/clients/digitalbrain_flutter/shell/lib/main.dart +++ b/clients/digitalbrain_flutter/shell/lib/main.dart @@ -23,9 +23,10 @@ void main() { chatName: chat, statusMessage: status, turns: edge?.watchChatTurns(chatName: chat), + topology: edge?.watchBrainTopology(), onSend: edge == null ? null : (text) => edge.sendMessage(chatName: chat, text: text), ), ); -} \ No newline at end of file +} diff --git a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart index 7112ec2c5..a3118fff2 100644 --- a/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart +++ b/clients/digitalbrain_flutter/shell/test/chat_screen_test.dart @@ -5,14 +5,334 @@ import 'package:digitalbrain_flutter_shell/chat_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -ChatTurnEvent _turn(int sequence, bool fromUser, String text) => ChatTurnEvent( - sequence: sequence, - fromUser: fromUser, - text: text, - commandId: 'c$sequence', - ); +ChatTurnEvent _turn( + int sequence, + bool fromUser, + String text, { + String? synapse, +}) => ChatTurnEvent( + sequence: sequence, + fromUser: fromUser, + text: text, + commandId: 'c$sequence', + synapse: synapse ?? (fromUser ? 'UserMessaged' : 'AssistantResponded'), + neuronId: 'chat:owner/main', + caller: 'chat:owner/main', + correlationId: 'correlation-$sequence', + timestamp: DateTime.utc(2026, 7, 28, 8, 0, sequence), +); + +BrainTopologySnapshot _topology() => BrainTopologySnapshot( + modules: const [ + BrainModule(id: 'DigitalBrain.Chat.ChatModule'), + BrainModule(id: 'DigitalBrain.AI.AIModule'), + BrainModule(id: 'DigitalBrain.Flutter.FlutterModule'), + BrainModule(id: 'DigitalBrain.Google.GoogleModule'), + BrainModule(id: 'DigitalBrain.OS.OSBehaviorsModule'), + BrainModule(id: 'DigitalBrain.Salesforce.SalesforceModule'), + ], + neurons: const [ + BrainNeuron( + id: 'chat:owner/main', + grainType: 'chat', + identity: 'owner/main', + placement: 'cluster-1', + ), + ], + observedAt: DateTime.utc(2026, 7, 28, 8), +); + +BrainTopologySnapshot _topologyWithoutNeuron() => BrainTopologySnapshot( + modules: _topology().modules, + neurons: const [], + observedAt: DateTime.utc(2026, 7, 28, 8), +); void main() { + testWidgets('the workspace exposes Chat, Activity, and Brain destinations', ( + tester, + ) async { + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('destination_chat')), findsOneWidget); + expect(find.byKey(const Key('destination_activity')), findsOneWidget); + expect(find.byKey(const Key('destination_brain')), findsOneWidget); + + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('activity_screen')), findsOneWidget); + expect(find.text('No activity yet.'), findsOneWidget); + + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('brain_screen')), findsOneWidget); + }); + + testWidgets('activity shows journal facts without message content', ( + tester, + ) async { + final turns = StreamController(); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}), + ); + + turns.add(_turn(1, true, 'private customer message')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + + expect(find.text('UserMessaged'), findsOneWidget); + expect(find.text('sequence 001'), findsOneWidget); + expect(find.text('command c1'), findsOneWidget); + expect(find.text('private customer message'), findsNothing); + }); + + testWidgets('activity renders the authoritative synapse name', ( + tester, + ) async { + final turns = StreamController(); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}), + ); + turns.add( + _turn(2, true, 'private payload', synapse: 'ObservedCustomSynapse'), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + + expect(find.text('ObservedCustomSynapse'), findsOneWidget); + expect(find.text('UserMessaged'), findsNothing); + expect(find.text('private payload'), findsNothing); + }); + + testWidgets('the shared event projection survives destination changes', ( + tester, + ) async { + final turns = StreamController(); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', turns: turns.stream, onSend: (_) async {}), + ); + + await tester.tap(find.byKey(const Key('destination_activity'))); + await tester.pumpAndSettle(); + turns.add(_turn(9, false, 'arrived while activity was open')); + await tester.pumpAndSettle(); + + expect(find.text('AssistantResponded'), findsOneWidget); + + await tester.tap(find.byKey(const Key('destination_chat'))); + await tester.pumpAndSettle(); + expect(find.text('arrived while activity was open'), findsOneWidget); + expect(find.text('009'), findsOneWidget); + }); + + testWidgets('narrow windows use bottom navigation', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const BrainChatApp(chatName: 'main')); + await tester.pumpAndSettle(); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(NavigationRail), findsNothing); + }); + + testWidgets('Brain renders live modules and active neurons', (tester) async { + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget); + expect(find.byKey(const Key('topology_module_0')), findsOneWidget); + expect(find.byKey(const Key('topology_module_1')), findsOneWidget); + expect(find.text('Chat'), findsWidgets); + expect(find.text('AI'), findsOneWidget); + expect(find.text('chat:owner/main'), findsOneWidget); + }); + + testWidgets('Brain clears a transient topology failure after recovery', ( + tester, + ) async { + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.text('Connected'), findsOneWidget); + expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget); + + topology.addError(StateError('topology temporarily unavailable')); + await tester.pumpAndSettle(); + + expect(find.text('Offline'), findsOneWidget); + expect(find.byKey(const Key('brain_topology_canvas')), findsNothing); + expect(find.text('Waiting for live topology…'), findsOneWidget); + + topology.add(_topology()); + await tester.pumpAndSettle(); + + expect(find.text('Connected'), findsOneWidget); + expect(find.byKey(const Key('brain_topology_canvas')), findsOneWidget); + }); + + testWidgets('a chat turn pulses Brain and opens correlation inspector', ( + tester, + ) async { + final topology = StreamController(); + final turns = StreamController(); + addTearDown(topology.close); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'main', + topology: topology.stream, + turns: turns.stream, + ), + ); + topology.add(_topology()); + turns.add(_turn(9, true, 'private pulse content')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_pulse')), findsOneWidget); + expect(find.byKey(const Key('brain_local_pulse')), findsOneWidget); + expect(find.byKey(const Key('brain_edge_pulse')), findsNothing); + expect(find.byKey(const Key('brain_inspector')), findsOneWidget); + expect(find.text('correlation-9'), findsOneWidget); + expect(find.text('chat:owner/main'), findsWidgets); + expect(find.text('private pulse content'), findsNothing); + }); + + testWidgets('a pulse waits for its neuron to appear in live topology', ( + tester, + ) async { + final topology = StreamController(); + final turns = StreamController(); + addTearDown(topology.close); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'main', + topology: topology.stream, + turns: turns.stream, + ), + ); + topology.add(_topologyWithoutNeuron()); + turns.add(_turn(12, false, 'not exposed by the topology view')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_pulse')), findsNothing); + + await tester.pump(const Duration(milliseconds: 1200)); + topology.add(_topology()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('brain_pulse')), findsOneWidget); + }); + + testWidgets('Brain clears a neuron selection when the neuron disappears', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final topology = StreamController(); + addTearDown(topology.close); + + await tester.pumpWidget( + BrainChatApp(chatName: 'main', topology: topology.stream), + ); + topology.add(_topology()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('topology_neuron_0'))); + await tester.pumpAndSettle(); + + expect(find.text('cluster-1'), findsOneWidget); + + topology.add(_topologyWithoutNeuron()); + await tester.pumpAndSettle(); + + expect(find.text('cluster-1'), findsNothing); + expect( + find.text( + 'Select a module or neuron. New chat turns open their causal pulse automatically.', + ), + findsOneWidget, + ); + }); + + testWidgets('Brain clears causal selection when the chat changes', ( + tester, + ) async { + final topology = StreamController.broadcast(); + final turns = StreamController.broadcast(); + addTearDown(topology.close); + addTearDown(turns.close); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'main', + topology: topology.stream, + turns: turns.stream, + ), + ); + topology.add(_topology()); + turns.add(_turn(14, false, 'private turn')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('destination_brain'))); + await tester.pumpAndSettle(); + + expect(find.text('correlation-14'), findsOneWidget); + + await tester.pumpWidget( + BrainChatApp( + chatName: 'other', + topology: topology.stream, + turns: turns.stream, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('correlation-14'), findsNothing); + }); + testWidgets('an empty conversation invites the owner to act', (tester) async { await tester.pumpWidget(const BrainChatApp(chatName: 'main')); await tester.pump(); @@ -22,8 +342,9 @@ void main() { expect(find.byKey(const Key('chat_journal')), findsNothing); }); - testWidgets('each turn carries its journal sequence and speaker', - (tester) async { + testWidgets('each turn carries its journal sequence and speaker', ( + tester, + ) async { final turns = StreamController(); addTearDown(turns.close); @@ -60,8 +381,9 @@ void main() { expect(find.text('only once'), findsOneWidget); }); - testWidgets('sending hands the text to the edge and awaits the brain', - (tester) async { + testWidgets('sending hands the text to the edge and awaits the brain', ( + tester, + ) async { final sent = []; final turns = StreamController(); addTearDown(turns.close); @@ -91,15 +413,16 @@ void main() { expect(find.text('Done.'), findsOneWidget); }); - testWidgets('a disconnected edge disables sending and says so', - (tester) async { + testWidgets('a disconnected edge disables sending and says so', ( + tester, + ) async { await tester.pumpWidget( const BrainChatApp(chatName: 'main', statusMessage: 'no edge'), ); await tester.pump(); expect(find.text('not connected'), findsOneWidget); - final send = tester.widget(find.byKey(const Key('chat_send'))); + final send = tester.widget(find.byKey(const Key('chat_send'))); expect(send.onPressed, isNull); }); } diff --git a/clients/digitalbrain_flutter/test/edge_client_test.dart b/clients/digitalbrain_flutter/test/edge_client_test.dart index e7c9f0310..72663b529 100644 --- a/clients/digitalbrain_flutter/test/edge_client_test.dart +++ b/clients/digitalbrain_flutter/test/edge_client_test.dart @@ -16,11 +16,7 @@ void main() { }), ); - await client.openScene( - shellName: 'desk', - sceneKey: 'home', - title: 'Home', - ); + await client.openScene(shellName: 'desk', sceneKey: 'home', title: 'Home'); expect(seen, isNotNull); expect(seen!.method, 'POST'); @@ -153,4 +149,140 @@ data: {"sequence":3,"sceneKey":"home","title":"Home refreshed","commandId":"c"," throwsA(isA()), ); }); + + test( + 'watchBrainTopology polls live modules and neurons without restart', + () async { + var requestCount = 0; + final client = DigitalBrainUiEdgeClient( + baseUri: Uri.parse('http://ui.example:5080'), + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect( + request.url.toString(), + 'http://ui.example:5080/brain/topology', + ); + requestCount++; + return http.Response( + jsonEncode({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': [ + if (requestCount > 1) + { + 'id': 'chat:owner/main', + 'grainType': 'chat', + 'identity': 'owner/main', + 'placement': 'cluster-1', + }, + ], + 'observedAt': '2026-07-28T08:00:00Z', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final snapshots = await client + .watchBrainTopology(pollInterval: Duration.zero) + .take(2) + .toList(); + + expect(snapshots.first.neurons, isEmpty); + expect(snapshots.last.neurons.single.id, 'chat:owner/main'); + }, + ); + + test( + 'watchBrainTopology reports a transient failure and keeps polling', + () async { + var requestCount = 0; + final client = DigitalBrainUiEdgeClient( + baseUri: Uri.parse('http://ui.example:5080'), + httpClient: MockClient((request) async { + requestCount++; + if (requestCount == 1) { + return http.Response('temporarily unavailable', 503); + } + return http.Response( + jsonEncode({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': const [], + 'observedAt': '2026-07-28T08:00:00Z', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + final errors = []; + + final snapshot = await client + .watchBrainTopology(pollInterval: Duration.zero) + .handleError(errors.add) + .first; + + expect(requestCount, 2); + expect(errors, hasLength(1)); + expect(snapshot.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + }, + ); + + test('watchBrainTopology aborts a hung request and keeps polling', () async { + final httpClient = _AbortThenSucceedClient(); + final client = DigitalBrainUiEdgeClient( + baseUri: Uri.parse('http://ui.example:5080'), + httpClient: httpClient, + ); + final errors = []; + + final snapshot = await client + .watchBrainTopology( + pollInterval: Duration.zero, + requestTimeout: const Duration(milliseconds: 1), + ) + .handleError(errors.add) + .first; + + expect(httpClient.requests, 2); + expect(httpClient.sawAbortableRequest, isTrue); + expect(errors.single, isA()); + expect(snapshot.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + }); +} + +final class _AbortThenSucceedClient extends http.BaseClient { + int requests = 0; + bool sawAbortableRequest = false; + + @override + Future send(http.BaseRequest request) async { + requests++; + if (requests == 1) { + sawAbortableRequest = request is http.AbortableRequest; + final abortable = request as http.AbortableRequest; + await abortable.abortTrigger; + throw http.RequestAbortedException(request.url); + } + + return http.StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': const [], + 'observedAt': '2026-07-28T08:00:00Z', + }), + ), + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } } diff --git a/clients/digitalbrain_flutter/test/shell_surface_test.dart b/clients/digitalbrain_flutter/test/shell_surface_test.dart index edbde8f0e..cc7ad0bf3 100644 --- a/clients/digitalbrain_flutter/test/shell_surface_test.dart +++ b/clients/digitalbrain_flutter/test/shell_surface_test.dart @@ -2,59 +2,65 @@ import 'package:digitalbrain_flutter/digitalbrain_flutter.dart'; import 'package:test/test.dart'; void main() { - test('ShellSurfaceController projects SceneOpened without dropping prior scenes', () { - final surface = ShellSurfaceController(); + test( + 'ShellSurfaceController projects SceneOpened without dropping prior scenes', + () { + final surface = ShellSurfaceController(); - surface.apply( - const SceneOpenedEvent( - sequence: 1, - sceneKey: 'home', - title: 'Home', - commandId: 'a', - shell: 'shell:dev/desk', - ), - ); - surface.apply( - const SceneOpenedEvent( - sequence: 2, - sceneKey: 'countdown', - title: 'Countdown', - commandId: 'b', - shell: 'shell:dev/desk', - ), - ); + surface.apply( + const SceneOpenedEvent( + sequence: 1, + sceneKey: 'home', + title: 'Home', + commandId: 'a', + shell: 'shell:dev/desk', + ), + ); + surface.apply( + const SceneOpenedEvent( + sequence: 2, + sceneKey: 'countdown', + title: 'Countdown', + commandId: 'b', + shell: 'shell:dev/desk', + ), + ); - expect(surface.scenes.map((s) => s.sceneKey), ['home', 'countdown']); - expect(surface.latest?.title, 'Countdown'); - expect(surface.latest?.sequence, 2); - }); + expect(surface.scenes.map((s) => s.sceneKey), ['home', 'countdown']); + expect(surface.latest?.title, 'Countdown'); + expect(surface.latest?.sequence, 2); + }, + ); - test('ShellSurfaceController replaces same sceneKey with newer projection', () { - final surface = ShellSurfaceController(); + test( + 'ShellSurfaceController replaces same sceneKey with newer projection', + () { + final surface = ShellSurfaceController(); - surface.apply( - const SceneOpenedEvent( - sequence: 1, - sceneKey: 'home', - title: 'Home', - commandId: 'a', - shell: 'shell:dev/desk', - ), - ); - surface.apply( - const SceneOpenedEvent( - sequence: 4, - sceneKey: 'home', - title: 'Home again', - commandId: 'c', - shell: 'shell:dev/desk', - ), - ); + surface.apply( + const SceneOpenedEvent( + sequence: 1, + sceneKey: 'home', + title: 'Home', + commandId: 'a', + shell: 'shell:dev/desk', + ), + ); + surface.apply( + const SceneOpenedEvent( + sequence: 4, + sceneKey: 'home', + title: 'Home again', + commandId: 'c', + shell: 'shell:dev/desk', + ), + ); - expect(surface.scenes, hasLength(1)); - expect(surface.latest?.title, 'Home again'); - expect(surface.latest?.sequence, 4); - }); + expect(surface.scenes, hasLength(1)); + expect(surface.latest?.title, 'Home again'); + expect(surface.latest?.sequence, 4); + }, + ); test( 'latest is most recently applied even when replace is not list-last', diff --git a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart index b3e622ded..12fd5576f 100644 --- a/clients/digitalbrain_wire/lib/src/ui_edge_models.dart +++ b/clients/digitalbrain_wire/lib/src/ui_edge_models.dart @@ -4,10 +4,7 @@ final class OpenSceneRequest { final String sceneKey; final String title; - Map toJson() => { - 'sceneKey': sceneKey, - 'title': title, - }; + Map toJson() => {'sceneKey': sceneKey, 'title': title}; } final class ActivateControlRequest { @@ -17,9 +14,9 @@ final class ActivateControlRequest { final String? sceneKey; Map toJson() => { - 'intent': intent, - if (sceneKey != null) 'sceneKey': sceneKey, - }; + 'intent': intent, + if (sceneKey != null) 'sceneKey': sceneKey, + }; } final class SceneOpenedEvent { @@ -62,12 +59,22 @@ final class ChatTurnEvent { required this.fromUser, required this.text, required this.commandId, + required this.synapse, + required this.neuronId, + required this.caller, + required this.correlationId, + required this.timestamp, }); final int sequence; final bool fromUser; final String text; final String commandId; + final String synapse; + final String neuronId; + final String caller; + final String correlationId; + final DateTime timestamp; factory ChatTurnEvent.fromJson(Map json) { return ChatTurnEvent( @@ -75,6 +82,74 @@ final class ChatTurnEvent { fromUser: json['fromUser'] as bool, text: json['text'] as String, commandId: json['commandId'] as String, + synapse: json['synapse'] as String, + neuronId: json['neuronId'] as String, + caller: json['caller'] as String, + correlationId: json['correlationId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String).toUtc(), + ); + } +} + +final class BrainModule { + const BrainModule({required this.id}); + + final String id; + + factory BrainModule.fromJson(Map json) { + return BrainModule(id: json['id'] as String); + } +} + +final class BrainNeuron { + const BrainNeuron({ + required this.id, + required this.grainType, + required this.identity, + required this.placement, + }); + + final String id; + final String grainType; + final String identity; + final String placement; + + factory BrainNeuron.fromJson(Map json) { + return BrainNeuron( + id: json['id'] as String, + grainType: json['grainType'] as String, + identity: json['identity'] as String, + placement: json['placement'] as String, + ); + } +} + +final class BrainTopologySnapshot { + const BrainTopologySnapshot({ + required this.modules, + required this.neurons, + required this.observedAt, + }); + + final List modules; + final List neurons; + final DateTime observedAt; + + factory BrainTopologySnapshot.fromJson(Map json) { + return BrainTopologySnapshot( + modules: (json['modules'] as List) + .map( + (module) => + BrainModule.fromJson(Map.from(module! as Map)), + ) + .toList(growable: false), + neurons: (json['neurons'] as List) + .map( + (neuron) => + BrainNeuron.fromJson(Map.from(neuron! as Map)), + ) + .toList(growable: false), + observedAt: DateTime.parse(json['observedAt'] as String).toUtc(), ); } } diff --git a/clients/digitalbrain_wire/test/ui_edge_models_test.dart b/clients/digitalbrain_wire/test/ui_edge_models_test.dart index 6c102a701..ea13bdbd1 100644 --- a/clients/digitalbrain_wire/test/ui_edge_models_test.dart +++ b/clients/digitalbrain_wire/test/ui_edge_models_test.dart @@ -18,21 +18,66 @@ void main() { expect(event.shell, 'shell:dev/desk'); }); - test('OpenSceneRequest encodes camelCase for POST /shells/{shell}/scenes', () { - expect( - const OpenSceneRequest(sceneKey: 'home', title: 'Home').toJson(), - {'sceneKey': 'home', 'title': 'Home'}, - ); - }); + test( + 'OpenSceneRequest encodes camelCase for POST /shells/{shell}/scenes', + () { + expect(const OpenSceneRequest(sceneKey: 'home', title: 'Home').toJson(), { + 'sceneKey': 'home', + 'title': 'Home', + }); + }, + ); test('ActivateControlRequest omits null sceneKey', () { - expect( - const ActivateControlRequest(intent: 'submit').toJson(), - {'intent': 'submit'}, - ); + expect(const ActivateControlRequest(intent: 'submit').toJson(), { + 'intent': 'submit', + }); expect( const ActivateControlRequest(intent: 'submit', sceneKey: 'home').toJson(), {'intent': 'submit', 'sceneKey': 'home'}, ); }); + + test('ChatTurnEvent carries durable pulse identity from the UI edge', () { + final event = ChatTurnEvent.fromJson({ + 'sequence': 9, + 'fromUser': true, + 'text': 'hello', + 'commandId': 'command-9', + 'synapse': 'UserMessaged', + 'neuronId': 'chat:owner/main', + 'caller': 'session:owner/session', + 'correlationId': 'correlation-9', + 'timestamp': '2026-07-28T08:00:00Z', + }); + + expect(event.synapse, 'UserMessaged'); + expect(event.neuronId, 'chat:owner/main'); + expect(event.caller, 'session:owner/session'); + expect(event.correlationId, 'correlation-9'); + expect(event.timestamp, DateTime.utc(2026, 7, 28, 8)); + }); + + test('BrainTopologySnapshot carries configured modules and live neurons', () { + final topology = BrainTopologySnapshot.fromJson({ + 'modules': [ + {'id': 'DigitalBrain.Chat.ChatModule'}, + ], + 'neurons': [ + { + 'id': 'chat:owner/main', + 'grainType': 'chat', + 'identity': 'owner/main', + 'placement': 'cluster-1', + }, + ], + 'observedAt': '2026-07-28T08:00:00Z', + }); + + expect(topology.modules.single.id, 'DigitalBrain.Chat.ChatModule'); + expect(topology.neurons.single.id, 'chat:owner/main'); + expect(topology.neurons.single.grainType, 'chat'); + expect(topology.neurons.single.placement, 'cluster-1'); + expect(topology.observedAt, DateTime.utc(2026, 7, 28, 8)); + }); } diff --git a/global.json b/global.json index ceef7c504..5c62de86f 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "11.0.100-preview.6.26359.118", "rollForward": "latestFeature", "allowPrerelease": true + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/os/DigitalBrain.OS.AppHost/AppHost.cs b/os/DigitalBrain.OS.AppHost/AppHost.cs index d1c8a2bb5..9409da674 100644 --- a/os/DigitalBrain.OS.AppHost/AppHost.cs +++ b/os/DigitalBrain.OS.AppHost/AppHost.cs @@ -10,28 +10,27 @@ using DigitalBrain.OS; using DigitalBrain.Salesforce; using DigitalBrain.Salesforce.Aspire.Hosting; +using Microsoft.Extensions.Hosting; var builder = DistributedApplication.CreateBuilder(args); var brain = builder.AddDigitalBrain(ProductSurfaceResources.Brain); -brain.AddModule(ai => ai - .WithLlm() - //.WithLlm() - //.WithLlm() - //.WithLlm() - ); +brain.AddModule(ai => +{ + ai.EnableSensitiveData = builder.Environment.IsDevelopment(); + ai.WithLlm(); +}); brain.AddModule(); brain.AddModule(); -brain.AddModule(flutter => flutter - .WithUIEdge() - .WithFlutterHost()); +brain.AddModule(flutter => flutter.WithUIEdge().WithFlutterHost()); brain.AddModule(google => google.WithGmail()); brain.AddModule(salesforce => salesforce.WithSalesforce()); var silo = builder.AddProject(ProductSurfaceResources.Silo) .WithReference(brain); +#pragma warning disable ASPIREMCP001 builder.AddProject(ProductSurfaceResources.Mcp) .WithReference(brain.AsClient()) .WaitFor(silo) @@ -41,6 +40,10 @@ .WithHttpEndpoint( port: ProductSurfaceResources.McpHttpPort, name: ProductSurfaceResources.McpHttpEndpointName, - isProxied: false); + isProxied: false) + .WithMcpServer( + ProductSurfaceResources.McpPath, + ProductSurfaceResources.McpHttpEndpointName); +#pragma warning restore ASPIREMCP001 builder.Build().Run(); diff --git a/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs b/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs index 058fe4d73..95515de2b 100644 --- a/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs +++ b/os/DigitalBrain.OS.AppHost/ProductSurfaceResources.cs @@ -4,5 +4,6 @@ internal static class ProductSurfaceResources public const string Silo = "silo"; public const string Mcp = "digitalbrain-mcp"; public const string McpHttpEndpointName = "http"; + public const string McpPath = "/mcp"; public const int McpHttpPort = 5000; } diff --git a/os/DigitalBrain.OS.AppHost/Properties/launchSettings.json b/os/DigitalBrain.OS.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..89c173335 --- /dev/null +++ b/os/DigitalBrain.OS.AppHost/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/os/DigitalBrain.OS.Behaviors/Assistant.cs b/os/DigitalBrain.OS.Behaviors/Assistant.cs index 085ee139e..1b8b4d9af 100644 --- a/os/DigitalBrain.OS.Behaviors/Assistant.cs +++ b/os/DigitalBrain.OS.Behaviors/Assistant.cs @@ -9,20 +9,37 @@ namespace DigitalBrain.OS; -internal sealed class Assistant( - [FromKeyedServices(typeof(Gemma4))] IChatClient chatClient) +internal sealed class Assistant([FromKeyedServices(typeof(Gemma4))] IChatClient chatClient) : Agent(chatClient), IAssistant { public const string EnrichAccountFromEmail = "enrich_account_from_email"; public const string DefaultGmailAccount = "gmail"; public const string DefaultSalesforceAccount = "salesforce"; + protected override string? Instructions => + """ + You are DigitalBrain, the AI assistant inside the DigitalBrain personal agent system. + Be precise about your identity and never invent tools, integrations, or completed actions. + + You can provide general conversational help using the current chat context. In this + deployment you have one external action: enrich_account_from_email. Given an exact Gmail + message ID and a 15- or 18-character Salesforce Account ID, it reads that specific Gmail + message and creates a durable proposal to replace that Account's Description. The proposal + is not written to Salesforce until the owner separately gives explicit approval. + + You cannot search or list Gmail, send email, directly update Salesforce, approve a proposal, + or create accounts, contacts, or leads. Ask for both required IDs when either is missing. + Do not call the tool merely to describe your capabilities. Never describe a proposal as a + completed update. If authentication or a provider is unavailable, report that limitation. + """; + protected override IReadOnlyList Tools => [ Capability( EnrichAccountFromEmail, - "Read a Gmail message and propose it as the description of a Salesforce account. " - + "The proposal always waits for the owner's approval before anything is written.", + "Given an exact Gmail message ID and a 15- or 18-character Salesforce Account ID, " + + "read only that message and create an approval-gated Account Description proposal. " + + "This cannot search Gmail or directly write Salesforce.", ([Description("Salesforce account id, 15 or 18 characters")] string accountId, [Description("Gmail message id to read")] string messageId) => ProposeEnrichmentAsync(accountId, messageId)), @@ -30,6 +47,9 @@ internal sealed class Assistant( private async Task ProposeEnrichmentAsync(string accountId, string messageId) { + ValidateAccountId(accountId); + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + var message = await Gmail().ReadMessage(messageId, CancellationToken.None); var mutation = await Salesforce().ProposeAccountDescription( CommandId.New(), @@ -42,11 +62,21 @@ private async Task ProposeEnrichmentAsync(string accountId, string messa + $"It is {mutation.State} and will not be written until you approve it."; } + private static void ValidateAccountId(string accountId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accountId); + + if (accountId.Length is not (15 or 18) + || accountId.Any(static character => + !char.IsAsciiLetterOrDigit(character))) + { + throw new ArgumentException("A Salesforce Account ID must be a 15- or 18-character alphanumeric value.", nameof(accountId)); + } + } + private IGmail Gmail() - => GrainFactory.GetGrain( - NeuronId.For(Id.Owner, DefaultGmailAccount).ToGrainId()); + => GrainFactory.GetGrain(NeuronId.For(Id.Owner, DefaultGmailAccount).ToGrainId()); private ISalesforce Salesforce() - => GrainFactory.GetGrain( - NeuronId.For(Id.Owner, DefaultSalesforceAccount).ToGrainId()); + => GrainFactory.GetGrain(NeuronId.For(Id.Owner, DefaultSalesforceAccount).ToGrainId()); } diff --git a/os/DigitalBrain.OS.Behaviors/ChatResponder.cs b/os/DigitalBrain.OS.Behaviors/ChatResponder.cs index 470fdb1e0..4340a59fd 100644 --- a/os/DigitalBrain.OS.Behaviors/ChatResponder.cs +++ b/os/DigitalBrain.OS.Behaviors/ChatResponder.cs @@ -18,8 +18,7 @@ public async ValueTask ExecuteAsync( ArgumentNullException.ThrowIfNull(context); cancellationToken.ThrowIfCancellationRequested(); - var answer = await context.Get(AssistantName).Respond( - [.. request.Transcript.Select(AsChatMessage)]); + var answer = await context.Get(AssistantName).Respond([.. request.Transcript.Select(AsChatMessage)]); return answer.Text; } diff --git a/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs b/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs index f4509dce6..4e356147f 100644 --- a/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs +++ b/os/DigitalBrain.OS.Behaviors/ChatResponderNeuron.cs @@ -39,26 +39,17 @@ public async Task HandleAsync(UserMessaged synapse, CancellationToken cancellati var context = new PreRailBehaviorContext( GrainFactory, - new BehaviorExecutionMetadata( - Id.Owner, - Identity, - Revision, - BehaviorExecutionId.New()), + new BehaviorExecutionMetadata(Id.Owner, Identity, Revision, BehaviorExecutionId.New()), TimeProvider, _state); var answer = await Program.ExecuteAsync(synapse, context, cancellationToken); - await SendAsync( - synapse.Chat, - new AssistantAnswered( - context.DeterministicCommandId(synapse.CommandId.ToString()), - answer)); + await SendAsync(synapse.Chat, new AssistantAnswered(context.DeterministicCommandId(synapse.CommandId.ToString()), answer)); await WriteStateAsync(CancellationToken.None); } private static BehaviorRevisionId RevisionOf(string seed) - => new(Convert.ToHexStringLower( - SHA256.HashData(Encoding.UTF8.GetBytes(seed)))); + => new(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(seed)))); } diff --git a/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs b/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs index 286f87ab9..8a737ad44 100644 --- a/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs +++ b/os/DigitalBrain.OS.Behaviors/PreRailBehaviorContext.cs @@ -21,8 +21,7 @@ public CommandId DeterministicCommandId(string purpose) { ArgumentException.ThrowIfNullOrWhiteSpace(purpose); - var seed = SHA256.HashData( - Encoding.UTF8.GetBytes($"{Execution.Behavior}/{Execution.Execution.Value}/{purpose}")); + var seed = SHA256.HashData(Encoding.UTF8.GetBytes($"{Execution.Behavior}/{Execution.Execution.Value}/{purpose}")); return new CommandId(new Guid(seed.AsSpan(0, 16))); } @@ -32,8 +31,7 @@ public TContract Get(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return grains.GetGrain( - NeuronId.For(Execution.Owner, name).ToGrainId()); + return grains.GetGrain(NeuronId.For(Execution.Owner, name).ToGrainId()); } public ValueTask ReadStateAsync(string key, CancellationToken cancellationToken = default) diff --git a/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj b/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj index d4d4b75bd..2bc0b4997 100644 --- a/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj +++ b/os/DigitalBrain.OS.Host/DigitalBrain.OS.Host.csproj @@ -11,6 +11,7 @@ + diff --git a/os/DigitalBrain.OS.Host/Program.cs b/os/DigitalBrain.OS.Host/Program.cs index 0538e94f9..9ed5ed8fb 100644 --- a/os/DigitalBrain.OS.Host/Program.cs +++ b/os/DigitalBrain.OS.Host/Program.cs @@ -1,10 +1,9 @@ using DigitalBrain.Kernel; - -const string HealthPath = "/health"; -const string HealthResponse = "healthy"; +using DigitalBrain.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); builder.AddKeyedAzureTableServiceClient("brain-reminders"); builder.UseOrleans(silo => silo @@ -12,5 +11,5 @@ .AddDigitalBrainJournalStorage(builder.Configuration)); var app = builder.Build(); -app.MapGet(HealthPath, static () => Results.Ok(HealthResponse)); +app.MapDefaultEndpoints(); app.Run(); diff --git a/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs b/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs new file mode 100644 index 000000000..57d16b52d --- /dev/null +++ b/os/DigitalBrain.OS.Mcp/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DigitalBrain.OS.Mcp.Tests")] diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj index 6c10b9179..0b0b6bb6d 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj +++ b/os/DigitalBrain.OS.Mcp/DigitalBrain.OS.Mcp.csproj @@ -9,9 +9,9 @@ - + diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs index ab05f14c1..1a3496f3f 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainIntrospectionTools.cs @@ -25,10 +25,11 @@ public async Task> ListActiveNeuronsAsync() return [ .. statistics + .Where(statistic => statistic.GrainId.Key.ToString()! + .StartsWith($"{brain.Owner.Value}/", StringComparison.Ordinal)) .Select(statistic => new ActiveNeuron( statistic.GrainId.Type.ToString()!, - statistic.GrainId.Key.ToString()!, - statistic.SiloAddress.ToString())) + statistic.GrainId.Key.ToString()!)) .OrderBy(neuron => neuron.GrainType, StringComparer.Ordinal) .ThenBy(neuron => neuron.Identity, StringComparer.Ordinal), ]; @@ -50,8 +51,7 @@ public async Task ReadNeuronJournalAsync( var direction = Enum.Parse(kind, ignoreCase: true); var neuron = new NeuronId(grainType, brain.Owner, name); - var session = grains.GetGrain( - ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); + var session = grains.GetGrain(ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); var read = await session.ReadNeuronJournal(neuron, direction, afterSequence); @@ -82,9 +82,7 @@ public async Task ReadChatTranscriptAsync( return new ChatTranscriptPage( chatName, [ - .. transcript.Turns.Select(turn => new ChatTranscriptTurn( - turn.FromUser ? "you" : "brain", - turn.Text)), + .. transcript.Turns.Select(turn => new ChatTranscriptTurn(turn.FromUser ? "you" : "brain", turn.Text)), ]); } } diff --git a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs index fcd7f6bbc..c815eac1a 100644 --- a/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs +++ b/os/DigitalBrain.OS.Mcp/DigitalBrainMcpTools.cs @@ -1,8 +1,8 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using DigitalBrain.AI.Ollama; +using DigitalBrain.Abstractions; +using DigitalBrain.Chat; using DigitalBrain.Client; -using Microsoft.Extensions.AI; using ModelContextProtocol.Server; namespace DigitalBrain.OS.Mcp; @@ -12,16 +12,106 @@ namespace DigitalBrain.OS.Mcp; "Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Constructed by the MCP server DI container via WithTools().")] -internal sealed class DigitalBrainMcpTools(IDigitalBrain brain) +internal sealed class DigitalBrainMcpTools(IDigitalBrain brain, IGrainFactory grains) { - [McpServerTool(Name = McpHost.AskLlama32ToolName)] - [Description("Ask the owner's Llama 3.2 neuron and return its ChatResponse.")] - public Task AskLlama32Async( - [Description("User prompt for Llama 3.2")] string prompt) + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + private const int DefaultTimeoutSeconds = 300; + private const int MaximumTimeoutSeconds = 300; + + [McpServerTool(Name = McpHost.SendChatMessageToolName)] + [Description( + "Send a message through the owner's DigitalBrain conversation and wait for the exact " + + "correlated assistant response. This is the same durable chat path used by the product UI.")] + public async Task SendChatMessageAsync( + [Description("Message to send to DigitalBrain")] string text, + [Description("Caller-generated command id used to resume an interrupted call")] + string commandId, + [Description("Conversation name, for example 'main'")] string chatName = "main", + [Description("Maximum wait in seconds, from 1 through 300")] + int timeoutSeconds = DefaultTimeoutSeconds, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(commandId); + ArgumentException.ThrowIfNullOrWhiteSpace(chatName); + ArgumentOutOfRangeException.ThrowIfLessThan(timeoutSeconds, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(timeoutSeconds, MaximumTimeoutSeconds); + + if (!Guid.TryParse(commandId, out var commandIdentity) + || commandIdentity == Guid.Empty) + { + throw new ArgumentException( + "The command id must be a non-empty GUID.", + nameof(commandId)); + } + + var chatId = NeuronId.For(brain.Owner, chatName); + var session = grains.GetGrain(ISessionNeuron.ForOwner(brain.Owner).ToGrainId()); + var command = new CommandId(commandIdentity); + + await brain.Get(chatName).Send(new SendMessage(command, text)); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + return await WaitForResponseAsync( + session, + chatId, + chatName, + command, + afterSequence: 0, + cancellationToken: timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"DigitalBrain did not answer command '{commandId}' in conversation " + + $"'{chatName}' within {timeoutSeconds} seconds."); + } + } + + private static async Task WaitForResponseAsync( + ISessionNeuron session, + NeuronId chatId, + string chatName, + CommandId commandId, + long afterSequence, + CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + var cursor = afterSequence; + CorrelationId? correlation = null; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await session.ReadNeuronJournal(chatId, JournalKind.Outgoing, cursor); + + foreach (var delivery in page.Delta) + { + if (delivery.Synapse is UserMessaged messaged + && messaged.CommandId == commandId) + { + correlation = delivery.CorrelationId; + } + + if (delivery.Synapse is AssistantResponded response + && correlation is { } expected + && delivery.CorrelationId == expected) + { + return new ChatMessageResult( + chatName, + commandId.ToString(), + expected.Value.ToString("N"), + response.Text, + delivery.Sequence, + delivery.Timestamp); + } + } - return brain.Get(McpHost.DefaultLlama32Key).Respond( - [new ChatMessage(ChatRole.User, prompt)]); + cursor = page.ResumeSequence; + await Task.Delay(PollInterval, cancellationToken); + } } } diff --git a/os/DigitalBrain.OS.Mcp/McpHost.cs b/os/DigitalBrain.OS.Mcp/McpHost.cs index fecae8c38..5e35e3730 100644 --- a/os/DigitalBrain.OS.Mcp/McpHost.cs +++ b/os/DigitalBrain.OS.Mcp/McpHost.cs @@ -3,10 +3,7 @@ namespace DigitalBrain.OS.Mcp; internal static class McpHost { public const string EndpointPath = "/mcp"; - public const string HealthPath = "/health"; - public const string HealthResponse = "healthy"; - public const string AskLlama32ToolName = "ask_llama32"; - public const string DefaultLlama32Key = "default"; + public const string SendChatMessageToolName = "send_chat_message"; public const string ListActiveNeuronsToolName = "list_active_neurons"; public const string ReadNeuronJournalToolName = "read_neuron_journal"; public const string ReadChatTranscriptToolName = "read_chat_transcript"; @@ -15,7 +12,6 @@ public static WebApplication MapMcpHost(this WebApplication app) { ArgumentNullException.ThrowIfNull(app); - app.MapGet(HealthPath, static () => Results.Ok(HealthResponse)); app.MapMcp(EndpointPath); return app; } diff --git a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs index 145d955a5..96dc4053a 100644 --- a/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs +++ b/os/DigitalBrain.OS.Mcp/NeuronJournalPage.cs @@ -7,17 +7,18 @@ internal sealed record NeuronJournalPage( bool Compacted, IReadOnlyList Entries); -internal sealed record JournaledSynapse( - long Sequence, - string Synapse, - string Caller, - string Correlation, - DateTimeOffset Timestamp); +internal sealed record JournaledSynapse(long Sequence, string Synapse, string Caller, string Correlation, DateTimeOffset Timestamp); -internal sealed record ActiveNeuron(string GrainType, string Identity, string Silo); +internal sealed record ActiveNeuron(string GrainType, string Identity); -internal sealed record ChatTranscriptPage( - string Chat, - IReadOnlyList Turns); +internal sealed record ChatTranscriptPage(string Chat, IReadOnlyList Turns); internal sealed record ChatTranscriptTurn(string Speaker, string Text); + +internal sealed record ChatMessageResult( + string Chat, + string CommandId, + string CorrelationId, + string Response, + long Sequence, + DateTimeOffset Timestamp); diff --git a/os/DigitalBrain.OS.Mcp/Program.cs b/os/DigitalBrain.OS.Mcp/Program.cs index 06111ac18..20a93d97d 100644 --- a/os/DigitalBrain.OS.Mcp/Program.cs +++ b/os/DigitalBrain.OS.Mcp/Program.cs @@ -1,16 +1,12 @@ using DigitalBrain.Aspire; using DigitalBrain.OS.Mcp; -using Microsoft.Extensions.AI; -using Orleans.Serialization; +using DigitalBrain.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); -builder.AddDigitalBrainClient(client => -{ - client.Services.AddSerializer(serializer => serializer.AddJsonSerializer( - static type => type == typeof(ChatMessage) || type == typeof(ChatResponse))); -}); +builder.AddDigitalBrainClient(); builder.Services .AddMcpServer() @@ -19,5 +15,6 @@ .WithTools(); var app = builder.Build(); +app.MapDefaultEndpoints(); app.MapMcpHost(); app.Run(); diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature b/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature index 49bb229a6..d4bc6f220 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Features/Chat.feature @@ -11,6 +11,14 @@ Feature: The operating system answers through a behaviour Then the conversation "main" journals the user message "how is my account?" And the conversation "main" journals the assistant reply "Your account is up to date." + Scenario: a greeting stays conversational + Given a DigitalBrain for the default owner + And the conversation "main" is observed + And the assistant will reply "Hello! How can I help?" + When the owner sends "hi" to the conversation "main" + Then the conversation "main" journals the assistant reply "Hello! How can I help?" + And the assistant selects no external capability + Scenario: the conversation remembers earlier turns Given a DigitalBrain for the default owner And the conversation "main" is observed diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs index 7436a22d2..ab519f828 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ActivationSteps.cs @@ -24,8 +24,7 @@ public async Task ThenActivationIsJournaled() { var brain = world.Neuron(IDigitalBrainNeuron.InstanceName); - var activated = await brain.Outgoing.NextAsync( - world.CancellationToken); + var activated = await brain.Outgoing.NextAsync(world.CancellationToken); Assert.Equal(world.Brain.Client.Owner, activated.Synapse.Owner); } @@ -46,9 +45,7 @@ public async Task ThenActivationCountIs(int expected) { var brain = world.Neuron(IDigitalBrainNeuron.InstanceName); - var activations = await brain.Outgoing.ReadAsync( - afterSequence: 0, - world.CancellationToken); + var activations = await brain.Outgoing.ReadAsync(afterSequence: 0, world.CancellationToken); Assert.Equal(expected, activations.Count); } diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs index 4cdf102d0..da6a00012 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Steps/ChatSteps.cs @@ -1,4 +1,5 @@ using DigitalBrain.Abstractions; +using DigitalBrain.AI; using DigitalBrain.Chat; using DigitalBrain.Testing; using Reqnroll; @@ -46,4 +47,15 @@ public async Task ThenTheTranscriptHasTurns(string conversation, int expected) Assert.Equal(expected, transcript.Turns.Count); } + + [Then("the assistant selects no external capability")] + public async Task ThenTheAssistantSelectsNoExternalCapability() + { + var selected = await world + .Neuron(ChatResponder.AssistantName) + .Outgoing + .ReadAsync(afterSequence: 0, world.CancellationToken); + + Assert.Empty(selected); + } } diff --git a/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs b/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs index a39da0807..7685b2152 100644 --- a/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs +++ b/os/tests/DigitalBrain.OS.Bdd.Tests/Support/BrainWorld.cs @@ -26,8 +26,7 @@ internal async Task OpenAsync() { await CloseAsync(); - _deadline = CancellationTokenSource.CreateLinkedTokenSource( - TestContext.Current.CancellationToken); + _deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); _deadline.CancelAfter(ScenarioDeadline); _brain = await OSCluster.Fixture.CreateBrainAsync(_deadline.Token); } diff --git a/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs b/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs index 52ed97c0c..ed5a9fd38 100644 --- a/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs +++ b/os/tests/DigitalBrain.OS.Composition.Tests/BehaviorOSActivationBoot.cs @@ -42,9 +42,7 @@ public async Task SecondActivateDoesNotEmitAgain() await test.Client.ActivateAsync(); - var activations = await brain.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var activations = await brain.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Single(activations); Assert.Equal(first.SynapseId, activations[0].SynapseId); } diff --git a/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs b/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs index 6644de665..f85614e4d 100644 --- a/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs +++ b/os/tests/DigitalBrain.OS.Composition.Tests/CompositionChatEdge.cs @@ -69,9 +69,7 @@ internal void Reset() } } - private ChatResponse NextResponse( - IEnumerable messages, - CancellationToken cancellationToken) + private ChatResponse NextResponse(IEnumerable messages, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(messages); cancellationToken.ThrowIfCancellationRequested(); diff --git a/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs b/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs index 7e0cbf61d..6845a5439 100644 --- a/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs +++ b/os/tests/DigitalBrain.OS.Composition.Tests/ShellAndSurfaceCompositions.cs @@ -99,12 +99,7 @@ public async Task AiPaneSurfaceOpensSceneAndResponds() var shell = test.Neuron(ShellName); test.Chat().Reply(PaneReply); - var response = await new AiPaneSurface().RunAsync( - test.Client, - ShellName, - ModelName, - PanePrompt, - cancellationToken); + var response = await new AiPaneSurface().RunAsync(test.Client, ShellName, ModelName, PanePrompt, cancellationToken); var opened = await shell.Outgoing.NextAsync(cancellationToken); Assert.Equal(AiPaneSurface.SceneKey, opened.Synapse.SceneKey); diff --git a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs index a3ced2e5d..af7b30cf5 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/AppHostCleanupContracts.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Xunit; using Xunit.Sdk; +using Xunit.v3; [assembly: Parallelization(Mode = ParallelMode.None)] @@ -27,9 +28,7 @@ public async Task AHungAppHostDisposalCompletesWithinTheCleanupBudget() var applicationHost = new ControllableHost(hangDuringDispose: true); var running = await CreateRunningAppHost(applicationHost); var disposal = running.DisposeAsync().AsTask(); - var bound = Task.Delay( - TimeSpan.FromSeconds(1), - TestContext.Current.CancellationToken); + var bound = Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); Assert.Same(disposal, await Task.WhenAny(disposal, bound)); var failure = await Record.ExceptionAsync(() => disposal); @@ -52,17 +51,13 @@ public async Task AppHostCleanupPreservesTheFirstFailureAndRetainsLaterFailures( var later = new IOException("dispose failed"); var applicationHost = new ControllableHost(primary, later); var releases = 0; - var running = await CreateRunningAppHost( - applicationHost, - _ => Interlocked.Increment(ref releases)); + var running = await CreateRunningAppHost(applicationHost, _ => Interlocked.Increment(ref releases)); - var failure = await Record.ExceptionAsync( - () => running.DisposeAsync().AsTask()); + var failure = await Record.ExceptionAsync(() => running.DisposeAsync().AsTask()); Assert.NotNull(failure); Assert.Same(primary, failure.InnerException); - var secondary = Assert.IsType( - primary.Data["AppHost cleanup secondary failures"]); + var secondary = Assert.IsType(primary.Data["AppHost cleanup secondary failures"]); Assert.Same(later, Assert.Single(secondary.InnerExceptions)); Assert.Equal(1, applicationHost.StopCalls); Assert.Equal(1, applicationHost.AsyncDisposeCalls); @@ -79,9 +74,7 @@ public async Task AppHostCleanupIsIdempotent() { var applicationHost = new ControllableHost(); var releases = 0; - var running = await CreateRunningAppHost( - applicationHost, - _ => Interlocked.Increment(ref releases)); + var running = await CreateRunningAppHost(applicationHost, _ => Interlocked.Increment(ref releases)); await running.DisposeAsync(); await running.DisposeAsync(); @@ -102,16 +95,10 @@ private static async Task CreateRunningAppHost( { var application = new DistributedApplication(applicationHost); var lease = await AcquireLeaseAsync(CancellationToken.None); - var constructor = typeof(RunningAppHost).GetConstructors( - BindingFlags.NonPublic | BindingFlags.Instance) + var constructor = typeof(RunningAppHost) + .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance) .Single(); - return (RunningAppHost)constructor.Invoke( - [ - application, - lease, - release ?? (_ => { }), - TestCleanupBudget, - ]); + return (RunningAppHost)constructor.Invoke([application, lease, release ?? (_ => { }), TestCleanupBudget]); } private static async Task AssertLeaseReleasedExactlyOnceAsync() @@ -119,20 +106,14 @@ private static async Task AssertLeaseReleasedExactlyOnceAsync() using var timeout = new CancellationTokenSource(TestCleanupBudget); var lease = await AcquireLeaseAsync(timeout.Token); using var contenderTimeout = new CancellationTokenSource(TestCleanupBudget); - await Assert.ThrowsAnyAsync( - () => AcquireLeaseAsync(contenderTimeout.Token)); + await Assert.ThrowsAnyAsync(() => AcquireLeaseAsync(contenderTimeout.Token)); await DisposeLeaseAsync(lease); } - private static async Task AcquireLeaseAsync( - CancellationToken cancellationToken) + private static async Task AcquireLeaseAsync(CancellationToken cancellationToken) { - var leaseType = typeof(RunningAppHost).Assembly.GetType( - "DigitalBrain.Testing.AppHostExclusiveLease", - throwOnError: true)!; - var acquire = leaseType.GetMethod( - "AcquireAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; + var leaseType = typeof(RunningAppHost).Assembly.GetType("DigitalBrain.Testing.AppHostExclusiveLease", throwOnError: true)!; + var acquire = leaseType.GetMethod("AcquireAsync", BindingFlags.NonPublic | BindingFlags.Static)!; var leaseTask = (Task)acquire.Invoke(null, [cancellationToken])!; await leaseTask; return leaseTask.GetType().GetProperty("Result")!.GetValue(leaseTask)!; @@ -141,12 +122,9 @@ private static async Task AcquireLeaseAsync( private static Task DisposeLeaseAsync(object lease) { var dispose = lease.GetType().GetMethod( - nameof(IAsyncDisposable.DisposeAsync), - BindingFlags.Public | BindingFlags.Instance)!; + nameof(IAsyncDisposable.DisposeAsync), BindingFlags.Public | BindingFlags.Instance)!; var valueTask = dispose.Invoke(lease, null)!; - return (Task)valueTask.GetType().GetMethod("AsTask")!.Invoke( - valueTask, - null)!; + return (Task)valueTask.GetType().GetMethod("AsTask")!.Invoke(valueTask, null)!; } private sealed class ControllableHost : IHost, IAsyncDisposable @@ -211,8 +189,7 @@ public Task StopAsync(CancellationToken cancellationToken = default) private static ServiceProvider CreateServices() { var builder = Host.CreateApplicationBuilder(); - builder.Services.AddSingleton( - new DistributedApplicationModel(Array.Empty())); + builder.Services.AddSingleton(new DistributedApplicationModel(Array.Empty())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); return builder.Services.BuildServiceProvider(); diff --git a/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs b/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs index 7e2322945..5829efb8a 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/FixtureExclusivity.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using DigitalBrain.Testing; using Xunit; namespace DigitalBrain.HostTests; @@ -6,31 +8,96 @@ public sealed class FixtureExclusivity( TestingAppHostFixture testing, QuickstartAppHostFixture quickstart) { - [Fact(DisplayName = - "a second graph waits for the first within the same AppHost fixture")] + private const int LiveAppHostTimeoutMs = 300_000; + + [Fact( + Timeout = LiveAppHostTimeoutMs, + DisplayName = "a second graph waits for the first within the same AppHost fixture")] public async Task ASecondGraphWaitsForTheFirstWithinTheSameFixture() { - var cancellationToken = TestContext.Current.CancellationToken; - await using var first = await testing.StartAsync(cancellationToken); - var waiting = testing.StartAsync(cancellationToken); - Assert.False(waiting.IsCompleted); - - await first.DisposeAsync(); - await using var second = await waiting; - Assert.NotNull(second); + await AssertSecondGraphWaitsAsync( + testing, + testing, + TestContext.Current.CancellationToken); } - [Fact(DisplayName = - "a second graph waits for the first across silo-only AppHost fixture types")] + [Fact( + Timeout = LiveAppHostTimeoutMs, + DisplayName = + "a second graph waits for the first across silo-only AppHost fixture types")] public async Task ASecondGraphWaitsForTheFirstAcrossFixtureTypes() { - var cancellationToken = TestContext.Current.CancellationToken; - await using var first = await testing.StartAsync(cancellationToken); - var waiting = quickstart.StartAsync(cancellationToken); - Assert.False(waiting.IsCompleted); - - await first.DisposeAsync(); - await using var second = await waiting; - Assert.NotNull(second); + await AssertSecondGraphWaitsAsync( + testing, + quickstart, + TestContext.Current.CancellationToken); + } + + private static async Task AssertSecondGraphWaitsAsync( + DigitalBrainAppHostFixture firstFixture, + DigitalBrainAppHostFixture secondFixture, + CancellationToken cancellationToken) + { + RunningAppHost? first = null; + Task? waiting = null; + try + { + first = await firstFixture.StartAsync(cancellationToken); + waiting = secondFixture.StartAsync(cancellationToken); + Assert.False(waiting.IsCompleted); + + await first.DisposeAsync(); + first = null; + + await using var second = await waiting.WaitAsync(cancellationToken); + waiting = null; + Assert.NotNull(second); + } + finally + { + await DisposeQuietlyAsync(first); + await DisposeStartedAsync(waiting); + } + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Cleanup after exclusivity failures must not mask the original test exception.")] + private static async Task DisposeQuietlyAsync(RunningAppHost? host) + { + if (host is null) + { + return; + } + + try + { + await host.DisposeAsync(); + } + catch + { + } + } + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Orphaned AppHost starts must be torn down without masking the original test exception.")] + private static async Task DisposeStartedAsync(Task? starting) + { + if (starting is null) + { + return; + } + + try + { + var host = await starting.WaitAsync(TimeSpan.FromMinutes(5)); + await DisposeQuietlyAsync(host); + } + catch + { + } } } diff --git a/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs b/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs index feace423d..f47e5bb47 100644 --- a/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs +++ b/os/tests/DigitalBrain.OS.Host.Tests/HostedBrain.cs @@ -4,8 +4,10 @@ namespace DigitalBrain.HostTests; public sealed class HostedBrain(TestingAppHostFixture fixture) { - [Fact(DisplayName = - "TestingAppHost silo-only residual: silo Healthy and health path OK (not product OS surface)")] + [Fact( + Timeout = 300_000, + DisplayName = + "TestingAppHost silo-only residual: silo Healthy and health path OK (not product OS surface)")] public async Task TheSiloReachesHealthyOnTheRealHost() { var cancellationToken = TestContext.Current.CancellationToken; @@ -15,7 +17,7 @@ public async Task TheSiloReachesHealthyOnTheRealHost() await silo.WaitUntilHealthyAsync(cancellationToken); using var client = silo.CreateHttpClient(); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromMinutes(5); using var health = await client.GetAsync( new Uri(TestingAppHostFixture.HealthPath, UriKind.Relative), cancellationToken); diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs new file mode 100644 index 000000000..39551f599 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ActiveNeuronTool.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using DigitalBrain.Chat; +using DigitalBrain.Testing; +using Xunit; + +namespace DigitalBrain.OS.Mcp.Tests; + +public sealed class ActiveNeuronTool(OSMcpFixture fixture) +{ + [Fact(DisplayName = "list_active_neurons is owner scoped and exposes no silo address")] + public async Task ListActiveNeuronsIsOwnerScopedAndRedacted() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + var other = test.Owner("other"); + + await test.Client.Get("mine").Read(); + await other.Client.Get("theirs").Read(); + + var tools = new DigitalBrainIntrospectionTools(test.Client, test.Cluster.Client); + var neurons = await tools.ListActiveNeuronsAsync(); + var json = JsonSerializer.Serialize(neurons); + + Assert.Contains( + neurons, + neuron => neuron.GrainType == "chat" + && neuron.Identity == $"{test.Client.Owner.Value}/mine"); + Assert.DoesNotContain( + neurons, + neuron => neuron.Identity.StartsWith( + $"{other.Id.Value}/", + StringComparison.Ordinal)); + Assert.DoesNotContain("\"silo\"", json, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs new file mode 100644 index 000000000..afab76ff8 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using DigitalBrain.OS.Mcp.Tests; +using Xunit; + +[assembly: AssemblyFixture(typeof(OSMcpFixture))] diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs new file mode 100644 index 000000000..c27d4809f --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/ChatProductTool.cs @@ -0,0 +1,66 @@ +using DigitalBrain.Abstractions; +using DigitalBrain.Chat; +using DigitalBrain.Testing; +using Xunit; + +namespace DigitalBrain.OS.Mcp.Tests; + +public sealed class ChatProductTool(OSMcpFixture fixture) +{ + private const int TestTimeout = 60_000; + + [Fact(Timeout = TestTimeout, DisplayName = "send_chat_message returns the correlated assistant response")] + public async Task SendChatMessageReturnsCorrelatedAssistantResponse() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + test.Chat().Reply("Hello from DigitalBrain."); + var tools = new DigitalBrainMcpTools(test.Client, test.Cluster.Client); + var commandId = CommandId.New(); + + var result = await tools.SendChatMessageAsync( + "hi", + commandId.ToString(), + "main", + timeoutSeconds: 10, + cancellationToken: cancellationToken); + + Assert.Equal("main", result.Chat); + Assert.Equal("Hello from DigitalBrain.", result.Response); + Assert.Equal(commandId.ToString(), result.CommandId); + Assert.NotEqual(Guid.Empty.ToString("N"), result.CorrelationId); + Assert.True(result.Sequence > 0); + + await test.Neuron("main").RestartHostAsync(cancellationToken); + var retried = await tools.SendChatMessageAsync( + "hi", + commandId.ToString(), + "main", + timeoutSeconds: 10, + cancellationToken: cancellationToken); + + Assert.Equal(result, retried); + + var transcript = await test.Client.Get("main").Read(); + Assert.Collection( + transcript.Turns, + user => + { + Assert.True(user.FromUser); + Assert.Equal("hi", user.Text); + }, + assistant => + { + Assert.False(assistant.FromUser); + Assert.Equal(result.Response, assistant.Text); + }); + + await Assert.ThrowsAsync( + () => tools.SendChatMessageAsync( + "different text", + commandId.ToString(), + "main", + timeoutSeconds: 10, + cancellationToken: cancellationToken)); + } +} diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj b/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj new file mode 100644 index 000000000..5167e537c --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/DigitalBrain.OS.Mcp.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + $(NoWarn);CA2007;CA1515 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs b/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs new file mode 100644 index 000000000..86fcbebc0 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Mcp.Tests/OSMcpFixture.cs @@ -0,0 +1,19 @@ +using DigitalBrain.AI; +using DigitalBrain.AI.Ollama; +using DigitalBrain.Chat; +using DigitalBrain.OS; +using DigitalBrain.Testing; + +namespace DigitalBrain.OS.Mcp.Tests; + +public sealed class OSMcpFixture : DigitalBrainFixture +{ + protected override void Configure(DigitalBrainTestBuilder brain) + { + ArgumentNullException.ThrowIfNull(brain); + brain.AddModule(); + brain.AddModule(); + brain.AddModule(); + brain.ConfigureScriptedChat(typeof(Gemma4)); + } +} diff --git a/os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj b/os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj new file mode 100644 index 000000000..d3277bd52 --- /dev/null +++ b/os/tests/DigitalBrain.OS.Product.Tests/DigitalBrain.OS.Product.Tests.csproj @@ -0,0 +1,18 @@ + + + Exe + $(NoWarn);CA2007;CA1515 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs b/os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs new file mode 100644 index 000000000..58e5ee09e --- /dev/null +++ b/os/tests/DigitalBrain.OS.Product.Tests/LiveProduct.cs @@ -0,0 +1,430 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; + +namespace DigitalBrain.ProductTests; + +public sealed class LiveProduct +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(5); + private const string AppHostPath = "os/DigitalBrain.OS.AppHost/DigitalBrain.OS.AppHost.csproj"; + private const string McpResource = "digitalbrain-mcp"; + + [Fact( + Explicit = true, + Timeout = 900_000, + DisplayName = + "LIVE product: real Gemma chat is idempotent, journaled, owner-scoped and emits content-rich GenAI telemetry in Development")] + public async Task RealChatIsObservable() + { + var cancellationToken = TestContext.Current.CancellationToken; + var repository = FindRepositoryRoot(); + var chatName = $"product-verification-{Guid.NewGuid():N}"; + var commandId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + const string prompt = "Tell me your name in one short sentence."; + var started = false; + + await RunAspireAsync( + repository, + allowFailure: true, + CancellationToken.None, + "stop", + "--apphost", + AppHostPath, + "--non-interactive", + "--nologo"); + + try + { + await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "start", + "--apphost", + AppHostPath, + "--format", + "Json", + "--non-interactive", + "--nologo"); + started = true; + + foreach (var resource in new[] { "silo", McpResource, "brain-ai-gemma4" }) + { + await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "wait", + resource, + "--apphost", + AppHostPath, + "--timeout", + "300", + "--non-interactive", + "--nologo"); + } + + var sendInput = JsonSerializer.Serialize( + new + { + text = prompt, + commandId, + chatName, + timeoutSeconds = 300, + }); + var result = await CallToolAsync( + repository, + "send_chat_message", + sendInput, + cancellationToken); + var response = RequiredString(result, "response"); + var correlationId = RequiredString(result, "correlationId"); + + Assert.False(string.IsNullOrWhiteSpace(response)); + Assert.True(Guid.TryParse(RequiredString(result, "commandId"), out _)); + Assert.True(Guid.TryParse(correlationId, out _)); + + var retried = await CallToolAsync( + repository, + "send_chat_message", + sendInput, + cancellationToken); + Assert.Equal(response, RequiredString(retried, "response")); + Assert.Equal(correlationId, RequiredString(retried, "correlationId")); + + var transcript = await CallToolAsync( + repository, + "read_chat_transcript", + JsonSerializer.Serialize(new { chatName }), + cancellationToken); + var turns = RequiredArray(transcript, "turns"); + Assert.Collection( + turns, + turn => + { + Assert.Equal("you", RequiredString(turn, "speaker")); + Assert.Equal(prompt, RequiredString(turn, "text")); + }, + turn => + { + Assert.Equal("brain", RequiredString(turn, "speaker")); + Assert.Equal(response, RequiredString(turn, "text")); + }); + + var journal = await CallToolAsync( + repository, + "read_neuron_journal", + JsonSerializer.Serialize( + new + { + grainType = "chat", + name = chatName, + kind = "outgoing", + afterSequence = 0, + }), + cancellationToken); + var entries = RequiredArray(journal, "entries"); + Assert.Collection( + entries, + entry => + { + Assert.Equal("UserMessaged", RequiredString(entry, "synapse")); + Assert.Equal(correlationId, RequiredString(entry, "correlation")); + }, + entry => + { + Assert.Equal("AssistantResponded", RequiredString(entry, "synapse")); + Assert.Equal(correlationId, RequiredString(entry, "correlation")); + }); + + var activeNeurons = await CallToolAsync( + repository, + "list_active_neurons", + "{}", + cancellationToken); + var activeChat = activeNeurons + .AsArray() + .Single(neuron => + string.Equals(RequiredString(neuron, "grainType"), "chat", StringComparison.Ordinal) + && string.Equals( + RequiredString(neuron, "identity"), + $"dev/{chatName}", + StringComparison.Ordinal)); + Assert.NotNull(activeChat); + Assert.DoesNotContain( + "\"silo\"", + activeNeurons.ToJsonString(), + StringComparison.OrdinalIgnoreCase); + + var span = await WaitForGenAiSpanAsync(repository, prompt, cancellationToken); + var attributes = RequiredObject(span, "attributes"); + + Assert.Equal("chat", RequiredString(attributes, "gen_ai.operation.name")); + Assert.Equal("ollama", RequiredString(attributes, "gen_ai.provider.name")); + Assert.Contains( + "gemma4", + RequiredString(attributes, "gen_ai.request.model"), + StringComparison.OrdinalIgnoreCase); + Assert.True(RequiredLong(attributes, "gen_ai.usage.input_tokens") > 0); + Assert.True(RequiredLong(attributes, "gen_ai.usage.output_tokens") > 0); + Assert.False( + string.IsNullOrWhiteSpace( + RequiredString(attributes, "gen_ai.response.finish_reasons"))); + Assert.Contains( + prompt, + RequiredString(attributes, "gen_ai.input.messages"), + StringComparison.Ordinal); + Assert.Contains( + response, + RequiredString(attributes, "gen_ai.output.messages"), + StringComparison.Ordinal); + } + finally + { + if (started) + { + await RunAspireAsync( + repository, + allowFailure: true, + CancellationToken.None, + "stop", + "--apphost", + AppHostPath, + "--non-interactive", + "--nologo"); + } + } + } + + private static async Task CallToolAsync( + string repository, + string tool, + string input, + CancellationToken cancellationToken) + { + var result = await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "mcp", + "call", + McpResource, + tool, + "--input", + input, + "--apphost", + AppHostPath, + "--non-interactive", + "--nologo"); + return ParseJson(result.StandardOutput); + } + + private static async Task WaitForGenAiSpanAsync( + string repository, + string prompt, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 60; attempt++) + { + var result = await RunAspireAsync( + repository, + allowFailure: false, + cancellationToken, + "otel", + "spans", + "--apphost", + AppHostPath, + "--format", + "Json", + "--limit", + "100", + "--search", + "gen_ai", + "--non-interactive", + "--nologo"); + var spans = ParseJson(result.StandardOutput).AsArray(); + var span = spans + .OfType() + .FirstOrDefault(candidate => + { + var attributes = candidate["attributes"] as JsonObject; + return attributes is not null + && string.Equals( + OptionalString(attributes, "gen_ai.operation.name"), + "chat", + StringComparison.Ordinal) + && string.Equals( + OptionalString(attributes, "gen_ai.provider.name"), + "ollama", + StringComparison.Ordinal) + && OptionalString(attributes, "gen_ai.input.messages") + ?.Contains(prompt, StringComparison.Ordinal) is true; + }); + + if (span is not null) + { + return span; + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + throw new Xunit.Sdk.XunitException( + "No content-rich Ollama GenAI chat span arrived within one minute."); + } + + private static async Task RunAspireAsync( + string repository, + bool allowFailure, + CancellationToken cancellationToken, + params string[] arguments) + { + using var commandTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + commandTimeout.CancelAfter(CommandTimeout); + + var start = new ProcessStartInfo("aspire") + { + WorkingDirectory = repository, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = start }; + if (!process.Start()) + { + throw new Xunit.Sdk.XunitException("The Aspire CLI process did not start."); + } + + var standardOutput = process.StandardOutput.ReadToEndAsync(commandTimeout.Token); + var standardError = process.StandardError.ReadToEndAsync(commandTimeout.Token); + + try + { + await process.WaitForExitAsync(commandTimeout.Token); + } + catch + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + throw; + } + + var result = new CommandResult( + process.ExitCode, + await standardOutput, + await standardError); + + if (!allowFailure && result.ExitCode != 0) + { + throw new Xunit.Sdk.XunitException( + $"aspire {string.Join(' ', arguments)} exited with {result.ExitCode}.{Environment.NewLine}" + + result.StandardOutput + + Environment.NewLine + + result.StandardError); + } + + return result; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "DigitalBrain.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new Xunit.Sdk.XunitException( + $"Could not find DigitalBrain.slnx above {AppContext.BaseDirectory}."); + } + + private static JsonNode ParseJson(string output) + { + var lines = output.Split( + ["\r\n", "\n"], + StringSplitOptions.None); + var firstJsonLine = Array.FindIndex( + lines, + line => + { + var trimmed = line.TrimStart(); + return trimmed.StartsWith('{', StringComparison.Ordinal) + || trimmed.StartsWith('[', StringComparison.Ordinal); + }); + + if (firstJsonLine < 0) + { + throw new Xunit.Sdk.XunitException( + $"The Aspire CLI did not return JSON.{Environment.NewLine}{output}"); + } + + return JsonNode.Parse(string.Join(Environment.NewLine, lines[firstJsonLine..])) + ?? throw new Xunit.Sdk.XunitException("The Aspire CLI returned JSON null."); + } + + private static JsonArray RequiredArray(JsonNode? node, string property) + => node?[property] as JsonArray + ?? throw new Xunit.Sdk.XunitException( + $"Expected JSON array '{property}' in {node?.ToJsonString()}."); + + private static JsonObject RequiredObject(JsonNode? node, string property) + => node?[property] as JsonObject + ?? throw new Xunit.Sdk.XunitException( + $"Expected JSON object '{property}' in {node?.ToJsonString()}."); + + private static string RequiredString(JsonNode? node, string property) + => OptionalString(node, property) + ?? throw new Xunit.Sdk.XunitException( + $"Expected JSON string '{property}' in {node?.ToJsonString()}."); + + private static string? OptionalString(JsonNode? node, string property) + { + var value = node?[property]; + if (value is null) + { + return null; + } + + return value is JsonValue jsonValue + && jsonValue.TryGetValue(out var text) + ? text + : value.ToJsonString(); + } + + private static long RequiredLong(JsonNode? node, string property) + { + var value = node?[property]; + if (value is JsonValue jsonValue + && jsonValue.TryGetValue(out var number)) + { + return number; + } + + if (long.TryParse(OptionalString(node, property), CultureInfo.InvariantCulture, out number)) + { + return number; + } + + throw new Xunit.Sdk.XunitException( + $"Expected JSON integer '{property}' in {node?.ToJsonString()}."); + } + + private sealed record CommandResult( + int ExitCode, + string StandardOutput, + string StandardError); +} diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs index f01974391..510f29b4e 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingProjectionSupport.cs @@ -18,17 +18,11 @@ internal static class FlutterHostingProjectionSupport "DigitalBrain.Modules.Flutter.Http", "DigitalBrain.Modules.Flutter.Http.csproj"); - public static string FlutterClientDirectory => RepositoryAssets.Path( - "clients", - "digitalbrain_flutter"); + public static string FlutterClientDirectory => RepositoryAssets.Path("clients", "digitalbrain_flutter"); - public static string FlutterShellDirectory => Path.Combine( - FlutterClientDirectory, - "shell"); + public static string FlutterShellDirectory => Path.Combine(FlutterClientDirectory, "shell"); - public static async Task AssertShellDesktopLayoutAsync( - string shellDirectory, - CancellationToken cancellationToken = default) + public static async Task AssertShellDesktopLayoutAsync(string shellDirectory, CancellationToken cancellationToken = default) { Assert.True( File.Exists(Path.Combine(shellDirectory, "pubspec.yaml")), @@ -50,9 +44,7 @@ public static async Task AssertShellDesktopLayoutAsync( Assert.Contains("sdk: flutter", pubspec, StringComparison.OrdinalIgnoreCase); } - public static async Task AssertPureDartClientLayoutAsync( - string clientDirectory, - CancellationToken cancellationToken = default) + public static async Task AssertPureDartClientLayoutAsync(string clientDirectory, CancellationToken cancellationToken = default) { Assert.True( File.Exists(Path.Combine(clientDirectory, "pubspec.yaml")), @@ -116,7 +108,10 @@ public static void AssertExclusiveFlutterHostEnvironment(HashSet environ environment); } - public static void AssertExclusiveUIProductEnvironment(IReadOnlyDictionary environment) + public static void AssertClientSafeUIProductEnvironment( + IReadOnlyDictionary environment, + IReadOnlyList modules, + IReadOnlyList configuredFeatures) { var productKeys = environment.Keys .Where(static key => @@ -125,12 +120,38 @@ public static void AssertExclusiveUIProductEnvironment(IReadOnlyDictionary(StringComparer.Ordinal) - { - FlutterHostingExtensions.OwnerEnvironmentVariable, - }, - productKeys); + var expected = new HashSet(StringComparer.Ordinal) + { + FlutterHostingExtensions.OwnerEnvironmentVariable, + }; + for (var index = 0; index < modules.Count; index++) + { + expected.Add( + $"{DigitalBrainHostingExtensions.ModulesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"); + } + for (var index = 0; index < configuredFeatures.Count; index++) + { + expected.Add( + $"{DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"); + } + + Assert.Equal(expected, productKeys); + for (var index = 0; index < modules.Count; index++) + { + Assert.Equal( + modules[index], + environment[ + $"{DigitalBrainHostingExtensions.ModulesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"] + ?.ToString()); + } + for (var index = 0; index < configuredFeatures.Count; index++) + { + Assert.Equal( + configuredFeatures[index], + environment[ + $"{DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey.Replace(":", "__", StringComparison.Ordinal)}__{index}"] + ?.ToString()); + } } public static async Task> EnvironmentKeysOf(IResource resource) diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs index 3242b5d41..6a36c4291 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingSelectionContracts.cs @@ -46,6 +46,11 @@ public async Task VocabularyOnlySelectionDoesNotStartOSSurface() entry.Value?.ToString(), FlutterModule.Id.Value, StringComparison.Ordinal)); + Assert.DoesNotContain( + siloEnvironment, + entry => entry.Key.StartsWith( + "DigitalBrain__ConfiguredFeatures__", + StringComparison.Ordinal)); FlutterHostingProjectionSupport.AssertNoOSSurfaceResources(builder); } diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs index f9f96d0f5..cb7a25414 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/FlutterHostingUIEdgeContracts.cs @@ -1,5 +1,8 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; +using DigitalBrain.AI; +using DigitalBrain.AI.Aspire.Hosting; +using DigitalBrain.AI.Ollama; using DigitalBrain.Aspire.Hosting; using DigitalBrain.Flutter; using DigitalBrain.Flutter.Aspire.Hosting; @@ -11,8 +14,8 @@ public sealed class FlutterHostingUIEdgeContracts { [Fact(DisplayName = "WithUIEdge projects " + FlutterHostingExtensions.DefaultUIResourceName - + " as AsClient with named http endpoint, exclusive owner env; omits flutter host")] - public async Task WithUIEdgeProjectsNamedHttpEndpointAsClientOnly() + + " as a client with late-bound module topology and no silo secrets")] + public async Task WithUIEdgeProjectsLateBoundModuleTopologyWithoutSiloSecrets() { var builder = DistributedApplication.CreateBuilder(); var brain = builder.AddDigitalBrain("brain"); @@ -22,6 +25,7 @@ public async Task WithUIEdgeProjectsNamedHttpEndpointAsClientOnly() options.ProjectPath = FlutterHostingProjectionSupport.UIProjectPath; options.Owner = "edge-owner"; })); + brain.AddModule(ai => ai.WithLlm()); var silo = builder .AddContainer("silo", "mcr.microsoft.com/dotnet/runtime") @@ -36,10 +40,19 @@ public async Task WithUIEdgeProjectsNamedHttpEndpointAsClientOnly() FlutterHostingProjectionSupport.AssertUIHasNamedHttpEndpoint(ui); var environment = await FlutterHostingProjectionSupport.EnvironmentOf(ui).ConfigureAwait(true); - FlutterHostingProjectionSupport.AssertExclusiveUIProductEnvironment(environment); + FlutterHostingProjectionSupport.AssertClientSafeUIProductEnvironment( + environment, + [FlutterModule.Id.Value, AIModule.Id.Value], + [AIHostingExtensions.Gemma4Feature]); + Assert.Equal( + AIHostingExtensions.Gemma4Feature, + environment["DigitalBrain__ConfiguredFeatures__0"]?.ToString()); Assert.Equal( "edge-owner", environment[FlutterHostingExtensions.OwnerEnvironmentVariable]?.ToString()); + Assert.DoesNotContain( + FlutterHostingProjectionSupport.JournalConnectionEnvironmentKey, + environment.Keys); Assert.Contains( ui.Annotations.OfType(), diff --git a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs index 5b9eda281..6658a5345 100644 --- a/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs +++ b/src/DigitalBrain.PublishGate.Tests/Hosting/HostingProjectionContracts.cs @@ -19,7 +19,6 @@ public sealed class HostingProjectionContracts [ FlutterHostingProjectionSupport.JournalConnectionEnvironmentKey, ConfigurationEnvironment(DigitalBrainHostingExtensions.StateProtectionKeyConfigurationKey), - $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__0", ConfigurationEnvironment("DigitalBrain:AI:Ollama:Endpoint"), ConfigurationEnvironment("DigitalBrain:Integrations:Mcp:AuthorizationMode"), ConfigurationEnvironment("DigitalBrain:Google:Gmail:ClientId"), @@ -27,12 +26,16 @@ public sealed class HostingProjectionContracts ]; [Fact(DisplayName = - "WithReference(brain) projects journal, state-protection key, AI config, and OAuth; AsClient never does")] - public async Task CompleteBrainReferenceProjectsSiloOnlySecretsAndClientDoesNot() + "complete and client references share module topology while only the complete reference receives secrets")] + public async Task ReferencesShareModuleTopologyWithoutLeakingSiloSecretsToClient() { var builder = DistributedApplication.CreateBuilder(); var brain = builder.AddDigitalBrain("brain"); - brain.AddModule(ai => ai.WithLlm()); + brain.AddModule(ai => + { + ai.EnableSensitiveData = true; + ai.WithLlm(); + }); brain.AddModule(google => google.WithGmail()); brain.AddModule(salesforce => salesforce.WithSalesforce()); @@ -50,11 +53,55 @@ public async Task CompleteBrainReferenceProjectsSiloOnlySecretsAndClientDoesNot( .EnvironmentKeysOf(silo.Resource) .ConfigureAwait(true); var clientEnvironment = await FlutterHostingProjectionSupport - .EnvironmentKeysOf(client.Resource) + .EnvironmentOf(client.Resource) .ConfigureAwait(true); + var clientEnvironmentKeys = clientEnvironment.Keys.ToHashSet(StringComparer.Ordinal); + var configuredFeaturePrefix = ConfigurationEnvironment( + DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey); Assert.All(SiloOnlyEnvironmentKeys, key => Assert.Contains(key, siloEnvironment)); - Assert.All(SiloOnlyEnvironmentKeys, key => Assert.DoesNotContain(key, clientEnvironment)); + Assert.All(SiloOnlyEnvironmentKeys, key => Assert.DoesNotContain(key, clientEnvironmentKeys)); + Assert.DoesNotContain( + siloEnvironment, + key => key.StartsWith(configuredFeaturePrefix, StringComparison.Ordinal)); + Assert.Equal( + AIModule.Id.Value, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__0"] + ?.ToString()); + Assert.Equal( + GoogleModule.Id.Value, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__1"] + ?.ToString()); + Assert.Equal( + SalesforceModule.Id.Value, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ModulesConfigurationKey)}__2"] + ?.ToString()); + Assert.Equal( + AIHostingExtensions.Llama32Feature, + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__0"] + ?.ToString()); + Assert.Equal( + bool.TrueString, + (await FlutterHostingProjectionSupport.EnvironmentOf(silo.Resource).ConfigureAwait(true))[ + "DigitalBrain__AI__Telemetry__EnableSensitiveData"] + ?.ToString()); + Assert.DoesNotContain( + "DigitalBrain__AI__Telemetry__EnableSensitiveData", + clientEnvironmentKeys); + Assert.Equal( + "google.gmail", + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__1"] + ?.ToString()); + Assert.Equal( + "salesforce", + clientEnvironment[ + $"{ConfigurationEnvironment(DigitalBrainHostingExtensions.ConfiguredFeaturesConfigurationKey)}__2"] + ?.ToString()); Assert.Contains( silo.Resource.Annotations.OfType(), diff --git a/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs b/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs index 3729c44fe..804bd0009 100644 --- a/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs +++ b/src/DigitalBrain.PublishGate.Tests/Identity/GrainKeyEncoding.cs @@ -16,8 +16,7 @@ public void OwnerIdRejectsPartsThatWouldBreakGrainKeyEncoding(string value) [Theory] [MemberData(nameof(RejectedIdentityParts))] public void NeuronNameRejectsPartsThatWouldBreakGrainKeyEncoding(string name) - => Assert.Throws( - () => new NeuronId(nameof(NeuronId), new OwnerId(nameof(OwnerId)), name)); + => Assert.Throws(() => new NeuronId(nameof(NeuronId), new OwnerId(nameof(OwnerId)), name)); [Fact] public void OwnerIdRejectsNull() @@ -26,10 +25,7 @@ public void OwnerIdRejectsNull() [Fact] public void GrainKeyRoundTripsThroughOwnerAndName() { - var original = new NeuronId( - nameof(NeuronId), - new OwnerId(nameof(OwnerId)), - nameof(NeuronId.Name)); + var original = new NeuronId(nameof(NeuronId), new OwnerId(nameof(OwnerId)), nameof(NeuronId.Name)); Assert.Equal(original, NeuronId.FromGrainKey(original.Type, original.GrainKey)); } @@ -48,6 +44,5 @@ public void DistinctOwnersCannotProduceTheSameGrainKey() [InlineData("/x")] [InlineData("x/")] public void FromGrainKeyRejectsMalformedKeys(string grainKey) - => Assert.Throws( - () => NeuronId.FromGrainKey(nameof(NeuronId), grainKey)); + => Assert.Throws(() => NeuronId.FromGrainKey(nameof(NeuronId), grainKey)); } diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs index f3707ce59..f0a54d511 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainBuilder.cs @@ -11,6 +11,7 @@ namespace DigitalBrain.Aspire.Hosting; public sealed class DigitalBrainBuilder { private readonly IDistributedApplicationBuilder _builder; + private readonly List _configuredFeatures = []; private readonly List _modules = []; private readonly List _projections = []; private readonly List _startupDependencies = []; @@ -35,6 +36,8 @@ internal DigitalBrainBuilder( internal IResourceBuilder Journal { get; } + internal IReadOnlyList ConfiguredFeatures => _configuredFeatures; + internal IReadOnlyList Modules => _modules; internal OrleansService Orleans { get; } @@ -47,9 +50,7 @@ internal DigitalBrainBuilder( internal IDistributedApplicationBuilder GetApplicationBuilder() => _builder; - internal TState GetOrAddState( - Func create, - out bool added) + internal TState GetOrAddState(Func create, out bool added) where TState : class { ArgumentNullException.ThrowIfNull(create); @@ -79,6 +80,16 @@ internal void AddProjection(DigitalBrainModuleProjection projection) _projections.Add(projection); } + internal void ConfigureFeature(string feature) + { + ArgumentException.ThrowIfNullOrWhiteSpace(feature); + + if (!_configuredFeatures.Contains(feature, StringComparer.Ordinal)) + { + _configuredFeatures.Add(feature); + } + } + internal void RequireHealthyBeforeStart(IResource dependency) { ArgumentNullException.ThrowIfNull(dependency); @@ -98,11 +109,7 @@ internal void RequireStateProtection() var name = $"{Name}-state-protection-key"; _stateProtectionKey = (_builder.ExecutionContext.IsRunMode - ? _builder.AddParameter( - name, - new StateProtectionKeyParameterDefault(), - secret: true, - persist: true) + ? _builder.AddParameter(name, new StateProtectionKeyParameterDefault(), secret: true, persist: true) : _builder.AddParameter(name, secret: true)) .WithDescription( "Base64-encoded 256-bit key shared by every silo that recovers encrypted durable module state."); @@ -112,8 +119,7 @@ internal void Select(ModuleId module) { if (_modules.Contains(module)) { - throw new InvalidOperationException( - $"{module} is already configured on brain '{Name}'. Add each module exactly once."); + throw new InvalidOperationException($"{module} is already configured on brain '{Name}'. Add each module exactly once."); } _modules.Add(module); @@ -124,12 +130,9 @@ internal void Select(ModuleId module) private sealed class StateProtectionKeyParameterDefault : ParameterDefault { public override string GetDefaultValue() - => Convert.ToBase64String( - RandomNumberGenerator.GetBytes(32)); + => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - public override void WriteToManifest( - ManifestPublishingContext context) - => throw new InvalidOperationException( - "Local state-protection defaults cannot be published."); + public override void WriteToManifest(ManifestPublishingContext context) + => throw new InvalidOperationException("Local state-protection defaults cannot be published."); } } diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs index b86e8f045..4a93d972d 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainHostingExtensions.cs @@ -9,10 +9,9 @@ public static class DigitalBrainHostingExtensions public const string JournalConnectionName = "journal"; public const string StateProtectionKeyConfigurationKey = "DigitalBrain:Security:StateProtectionKey"; public const string ModulesConfigurationKey = "DigitalBrain:Modules"; + public const string ConfiguredFeaturesConfigurationKey = "DigitalBrain:ConfiguredFeatures"; - public static DigitalBrainBuilder AddDigitalBrain( - this IDistributedApplicationBuilder builder, - string name) + public static DigitalBrainBuilder AddDigitalBrain(this IDistributedApplicationBuilder builder, string name) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -40,9 +39,7 @@ public static DigitalBrainBuilder AddModule(this DigitalBrainBuilder br where TModule : class, IModule, new() => brain.AddModule(static _ => { }); - public static DigitalBrainBuilder AddModule( - this DigitalBrainBuilder brain, - Action> configure) + public static DigitalBrainBuilder AddModule(this DigitalBrainBuilder brain, Action> configure) where TModule : class, IModule, new() { ArgumentNullException.ThrowIfNull(brain); @@ -52,9 +49,7 @@ public static DigitalBrainBuilder AddModule( return brain; } - public static IResourceBuilder WithReference( - this IResourceBuilder builder, - DigitalBrainBuilder brain) + public static IResourceBuilder WithReference(this IResourceBuilder builder, DigitalBrainBuilder brain) where TResource : IResourceWithEnvironment, IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); @@ -65,25 +60,15 @@ public static IResourceBuilder WithReference( foreach (var dependency in brain.StartupDependencies) { - builder.WithAnnotation(new WaitAnnotation( - dependency, - WaitType.WaitUntilHealthy, - exitCode: 0)); + builder.WithAnnotation(new WaitAnnotation(dependency, WaitType.WaitUntilHealthy, exitCode: 0)); } if (brain.StateProtectionKey is not null) { - builder.WithEnvironment( - ConfigurationEnvironment(StateProtectionKeyConfigurationKey), - brain.StateProtectionKey); + builder.WithEnvironment(ConfigurationEnvironment(StateProtectionKeyConfigurationKey), brain.StateProtectionKey); } - for (var index = 0; index < brain.Modules.Count; index++) - { - builder.WithEnvironment( - $"{ConfigurationEnvironment(ModulesConfigurationKey)}__{index}", - brain.Modules[index].Value); - } + ProjectModuleManifest(builder, brain); foreach (var projection in brain.Projections) { @@ -93,17 +78,42 @@ public static IResourceBuilder WithReference( return builder; } - public static IResourceBuilder WithReference( - this IResourceBuilder builder, - ClientDigitalBrainReference client) + public static IResourceBuilder WithReference(this IResourceBuilder builder, ClientDigitalBrainReference client) where TResource : IResourceWithEnvironment, IResourceWithEndpoints { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(client); - return builder.WithReference(client.Brain.Orleans.AsClient()); + builder.WithReference(client.Brain.Orleans.AsClient()); + ProjectModuleManifest(builder, client.Brain); + ProjectConfiguredFeatureManifest(builder, client.Brain); + return builder; } + private static void ProjectModuleManifest(IResourceBuilder builder, DigitalBrainBuilder brain) + where TResource : IResourceWithEnvironment + => builder.WithEnvironment(context => + { + for (var index = 0; index < brain.Modules.Count; index++) + { + context.EnvironmentVariables[ + $"{ConfigurationEnvironment(ModulesConfigurationKey)}__{index}"] = + brain.Modules[index].Value; + } + }); + + private static void ProjectConfiguredFeatureManifest(IResourceBuilder builder, DigitalBrainBuilder brain) + where TResource : IResourceWithEnvironment + => builder.WithEnvironment(context => + { + for (var index = 0; index < brain.ConfiguredFeatures.Count; index++) + { + context.EnvironmentVariables[ + $"{ConfigurationEnvironment(ConfiguredFeaturesConfigurationKey)}__{index}"] = + brain.ConfiguredFeatures[index]; + } + }); + private static string ConfigurationEnvironment(string configurationKey) => configurationKey.Replace(":", "__", StringComparison.Ordinal); } diff --git a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs index bc19e01bf..d21a6be0d 100644 --- a/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs +++ b/src/core/aspire/DigitalBrain.Aspire.Hosting/DigitalBrainModuleBuilder.cs @@ -12,6 +12,9 @@ public sealed class DigitalBrainModuleBuilder internal void AddProjection(DigitalBrainModuleProjection projection) => Brain.AddProjection(projection); + internal void ConfigureFeature(string feature) + => Brain.ConfigureFeature(feature); + internal void RequireStateProtection() => Brain.RequireStateProtection(); } diff --git a/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs b/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs index f5bc99e88..dbf0e3389 100644 --- a/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs +++ b/src/core/aspire/DigitalBrain.Aspire/DigitalBrainClientHostingExtensions.cs @@ -44,9 +44,7 @@ public static IHostApplicationBuilder AddDigitalBrainClient( } builder.Services.AddSingleton( - services => DigitalBrainClient.Connect( - services.GetRequiredService(), - owner)); + services => DigitalBrainClient.Connect(services.GetRequiredService(), owner)); builder.Services.AddHostedService(); return builder; diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj new file mode 100644 index 000000000..53b92bd06 --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/DigitalBrain.ServiceDefaults.Tests.csproj @@ -0,0 +1,17 @@ + + + Exe + $(NoWarn);CA2007;CA1515 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs new file mode 100644 index 000000000..9cbefccaf --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults.Tests/ServiceDefaultsContracts.cs @@ -0,0 +1,35 @@ +using DigitalBrain.ServiceDefaults; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +namespace DigitalBrain.ServiceDefaultsTests; + +public sealed class ServiceDefaultsContracts +{ + [Fact(DisplayName = "service defaults expose healthy readiness and liveness endpoints")] + public async Task ServiceDefaultsExposeHealthyReadinessAndLivenessEndpoints() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] = ""; + builder.AddServiceDefaults(); + + await using var app = builder.Build(); + app.MapDefaultEndpoints(); + + var routes = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(static source => source.Endpoints) + .OfType() + .Select(static endpoint => endpoint.RoutePattern.RawText) + .Order(StringComparer.Ordinal) + .ToArray(); + var health = await app.Services + .GetRequiredService() + .CheckHealthAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HealthStatus.Healthy, health.Status); + Assert.Equal(["/alive", "/health"], routes); + } +} diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj b/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj new file mode 100644 index 000000000..044872af0 --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults/DigitalBrain.ServiceDefaults.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs new file mode 100644 index 000000000..b1dce5415 --- /dev/null +++ b/src/core/aspire/DigitalBrain.ServiceDefaults/ServiceDefaultsExtensions.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace DigitalBrain.ServiceDefaults; + +public static class ServiceDefaultsExtensions +{ + private const string AlivePath = "/alive"; + private const string HealthPath = "/health"; + private const string TelemetryPrefix = "DigitalBrain"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + ConfigureOpenTelemetry(builder); + AddDefaultHealthChecks(builder); + + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + app.MapHealthChecks(HealthPath); + app.MapHealthChecks(AlivePath, new HealthCheckOptions + { + Predicate = static registration => registration.Tags.Contains("live"), + }); + + return app; + } + + private static void AddDefaultHealthChecks(TBuilder builder) + where TBuilder : IHostApplicationBuilder + => builder.Services + .AddHealthChecks() + .AddCheck("self", static () => HealthCheckResult.Healthy(), ["live"]); + + private static void AddOpenTelemetryExporters(TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + } + + private static void ConfigureOpenTelemetry(TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var configuredSampleRatio = builder.Configuration.GetValue("Telemetry:Tracing:SampleRatio"); + var sampleRatio = Math.Clamp(configuredSampleRatio ?? 1d, 0d, 1d); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService( + serviceName: builder.Environment.ApplicationName, + serviceNamespace: TelemetryPrefix)) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(TelemetryPrefix) + .AddMeter($"{TelemetryPrefix}.*") + .AddMeter("Experimental.Microsoft.Extensions.AI") + .AddMeter("Microsoft.Orleans")) + .WithTracing(tracing => tracing + .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(sampleRatio))) + .AddSource(builder.Environment.ApplicationName) + .AddSource(TelemetryPrefix) + .AddSource($"{TelemetryPrefix}.*") + .AddSource("Experimental.Microsoft.Extensions.AI") + .AddSource("Microsoft.Orleans.Application") + .AddAspNetCoreInstrumentation(options => + options.Filter = context => + !context.Request.Path.StartsWithSegments(HealthPath, StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments(AlivePath, StringComparison.OrdinalIgnoreCase)) + .AddHttpClientInstrumentation()); + + AddOpenTelemetryExporters(builder); + } +} diff --git a/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs b/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs index 115dd2e8b..de6498374 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/BehaviorExecutionMetadata.cs @@ -6,11 +6,7 @@ namespace DigitalBrain.Behaviors; [Alias("db.behavior-execution-metadata")] public sealed record BehaviorExecutionMetadata { - public BehaviorExecutionMetadata( - OwnerId Owner, - BehaviorId Behavior, - BehaviorRevisionId Revision, - BehaviorExecutionId Execution) + public BehaviorExecutionMetadata(OwnerId Owner, BehaviorId Behavior, BehaviorRevisionId Revision, BehaviorExecutionId Execution) { ArgumentException.ThrowIfNullOrWhiteSpace(Owner.Value); Behavior.EnsureValid(); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs b/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs index c39475a1b..b8a4f0128 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/IBehaviorProgram.cs @@ -5,8 +5,5 @@ namespace DigitalBrain.Behaviors; public interface IBehaviorProgram where TTrigger : Synapse { - ValueTask ExecuteAsync( - TTrigger trigger, - IBehaviorContext context, - CancellationToken cancellationToken); + ValueTask ExecuteAsync(TTrigger trigger, IBehaviorContext context, CancellationToken cancellationToken); } diff --git a/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs b/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs index 35cb44c99..9fb8d45ea 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/IIntentProgram.cs @@ -2,8 +2,5 @@ namespace DigitalBrain.Behaviors; public interface IIntentProgram { - ValueTask ExecuteAsync( - TRequest request, - IBehaviorContext context, - CancellationToken cancellationToken); + ValueTask ExecuteAsync(TRequest request, IBehaviorContext context, CancellationToken cancellationToken); } diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs index 40823de17..132c880a8 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorCapabilityGrant.cs @@ -1,6 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorCapabilityGrant( - string ContractAlias, - string MethodAlias, - string Target); +public sealed record BehaviorCapabilityGrant(string ContractAlias, string MethodAlias, string Target); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs index 965d183b5..71d41699f 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorEntryPoints.cs @@ -1,5 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorEntryPoints( - IReadOnlyList EventAliases, - IReadOnlyList IntentSchemas); +public sealed record BehaviorEntryPoints(IReadOnlyList EventAliases, IReadOnlyList IntentSchemas); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs index b07a2c26f..e87bb7415 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorIntentSchema.cs @@ -1,7 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorIntentSchema( - string SchemaId, - int SchemaVersion, - string RequestSchemaJson, - string ResultSchemaJson); +public sealed record BehaviorIntentSchema(string SchemaId, int SchemaVersion, string RequestSchemaJson, string ResultSchemaJson); diff --git a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs index 4d95bf0b5..2262b7cf5 100644 --- a/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs +++ b/src/core/behaviors/DigitalBrain.Behaviors/Manifest/BehaviorResourceLimits.cs @@ -1,6 +1,3 @@ namespace DigitalBrain.Behaviors.Manifest; -public sealed record BehaviorResourceLimits( - int CpuMilliseconds, - long MemoryBytes, - int WallClockMilliseconds); +public sealed record BehaviorResourceLimits(int CpuMilliseconds, long MemoryBytes, int WallClockMilliseconds); diff --git a/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs b/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs index 5b39d6460..519255f3f 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Journals/JournalTally.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.journal-tally")] -public sealed record JournalTally( - [property: Id(0)] string SynapseType, - [property: Id(1)] long Recorded); +public sealed record JournalTally([property: Id(0)] string SynapseType, [property: Id(1)] long Recorded); diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs index eac309b68..253f6c447 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/INeuron.cs @@ -3,12 +3,15 @@ namespace DigitalBrain.Abstractions; public partial interface INeuron : IGrainWithStringKey { [Alias(nameof(Deliver))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task Deliver(SynapseDelivery delivery); [Alias(nameof(ReadJournal))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task ReadJournal(JournalKind kind, long afterSequence); [Alias(nameof(Watch))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task Watch(JournalKind kind, long afterSequence, IJournalObserver observer); [Alias(nameof(Unwatch))] diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs index 22ce4d60f..884e63cec 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/ISessionNeuron.cs @@ -20,9 +20,11 @@ static NeuronId ForOwner(OwnerId owner) Task Emit(Synapse synapse); [Alias(nameof(ReadNeuronJournal))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task ReadNeuronJournal(NeuronId subject, JournalKind kind, long afterSequence); [Alias(nameof(WatchNeuron))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task WatchNeuron(NeuronId subject, JournalKind kind, long afterSequence, IJournalObserver observer); [Alias(nameof(UnwatchNeuron))] diff --git a/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs b/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs new file mode 100644 index 000000000..e883cf23b --- /dev/null +++ b/src/core/kernel/DigitalBrain.Abstractions/Neurons/NeuronCallTimeouts.cs @@ -0,0 +1,6 @@ +namespace DigitalBrain.Abstractions; + +public static class NeuronCallTimeouts +{ + public const string LongRunning = "00:05:00"; +} diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs index cc5e0e4b3..5783fc534 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityCompleted.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.capability-completed")] -public sealed record CapabilityCompleted( - [property: Id(0)] SynapseId Request) : Synapse; +public sealed record CapabilityCompleted([property: Id(0)] SynapseId Request) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs index ad822cf6d..fb782c76d 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityFailed.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.capability-failed")] -public sealed record CapabilityFailed( - [property: Id(0)] SynapseId Request) : Synapse; +public sealed record CapabilityFailed([property: Id(0)] SynapseId Request) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs index 1655660c7..af87d7aa2 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/CapabilityRejected.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.capability-rejected")] -public sealed record CapabilityRejected( - [property: Id(0)] SynapseId Request) : Synapse; +public sealed record CapabilityRejected([property: Id(0)] SynapseId Request) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs b/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs index 7df1aecd3..66d68b707 100644 --- a/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs +++ b/src/core/kernel/DigitalBrain.Abstractions/Synapses/DigitalBrainActivated.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Abstractions; [GenerateSerializer] [Alias("db.digitalbrain-activated")] -public sealed record DigitalBrainActivated( - [property: Id(0)] OwnerId Owner) : Synapse; +public sealed record DigitalBrainActivated([property: Id(0)] OwnerId Owner) : Synapse; diff --git a/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs b/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs index 8d13827ac..3d7146fb5 100644 --- a/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs +++ b/src/core/kernel/DigitalBrain.Client/DigitalBrainClient.cs @@ -43,9 +43,7 @@ public async Task SendAsync(string name, Synapse synapse) RequireDomainNeuronContract(typeof(TNeuron)); ArgumentNullException.ThrowIfNull(synapse); - await SendValidatedAsync( - new NeuronId(NeuronId.GrainTypeNameOf(typeof(TNeuron)), Owner, name), - synapse); + await SendValidatedAsync(new NeuronId(NeuronId.GrainTypeNameOf(typeof(TNeuron)), Owner, name), synapse); } public async Task SendAsync(NeuronId receiver, Synapse synapse) diff --git a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs index 68a49382b..2f1863e43 100644 --- a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs +++ b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Composition.cs @@ -117,9 +117,7 @@ private readonly struct ModuleModel(string name) public string Name { get; } = name; } - private readonly struct CompositionModel( - ImmutableArray modules, - bool emit) + private readonly struct CompositionModel(ImmutableArray modules, bool emit) { public ImmutableArray Modules { get; } = modules; diff --git a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs index 23e6bd117..f60abea80 100644 --- a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs +++ b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Modules.cs @@ -39,9 +39,7 @@ public sealed partial class DispatchManifestGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - private static ModuleCapsuleModel? ModuleOf( - GeneratorSyntaxContext syntax, - CancellationToken cancellationToken) + private static ModuleCapsuleModel? ModuleOf(GeneratorSyntaxContext syntax, CancellationToken cancellationToken) { if (syntax.Node is not ClassDeclarationSyntax declaration || syntax.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not INamedTypeSymbol module @@ -80,37 +78,25 @@ private static void EmitModule(SourceProductionContext production, ModuleCapsule if (!module.IsPartial) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleMustBePartial, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleMustBePartial, module.Location, module.FullName)); valid = false; } if (module.IsNested) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleMustBeTopLevel, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleMustBeTopLevel, module.Location, module.FullName)); valid = false; } if (module.IsGeneric) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleMustBeNonGeneric, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleMustBeNonGeneric, module.Location, module.FullName)); valid = false; } if (!module.HasPublicParameterlessConstructor) { - production.ReportDiagnostic(Diagnostic.Create( - ModuleNeedsPublicConstructor, - module.Location, - module.FullName)); + production.ReportDiagnostic(Diagnostic.Create(ModuleNeedsPublicConstructor, module.Location, module.FullName)); valid = false; } @@ -119,9 +105,7 @@ private static void EmitModule(SourceProductionContext production, ModuleCapsule return; } - production.AddSource( - $"{module.FullName}.CompiledModule.g.cs", - CompiledModule(module)); + production.AddSource($"{module.FullName}.CompiledModule.g.cs", CompiledModule(module)); } private static string CompiledModule(ModuleCapsuleModel module) diff --git a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs index 18aed89e9..64334c8d0 100644 --- a/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs +++ b/src/core/kernel/DigitalBrain.SourceGeneration/DispatchManifestGenerator.Neurons.cs @@ -23,9 +23,7 @@ public sealed partial class DispatchManifestGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - private static NeuronModel? NeuronOf( - GeneratorSyntaxContext syntax, - CancellationToken cancellationToken) + private static NeuronModel? NeuronOf(GeneratorSyntaxContext syntax, CancellationToken cancellationToken) { if (syntax.Node is not InterfaceDeclarationSyntax declaration || syntax.SemanticModel.GetDeclaredSymbol(declaration, cancellationToken) is not INamedTypeSymbol contract @@ -65,19 +63,13 @@ private static void EmitNeuron(SourceProductionContext production, NeuronModel n if (!neuron.IsPartial) { - production.ReportDiagnostic(Diagnostic.Create( - NeuronMustBePartial, - neuron.Location, - neuron.FullName)); + production.ReportDiagnostic(Diagnostic.Create(NeuronMustBePartial, neuron.Location, neuron.FullName)); valid = false; } if (neuron.HasAlias) { - production.ReportDiagnostic(Diagnostic.Create( - NeuronAliasIsGenerated, - neuron.Location, - neuron.FullName)); + production.ReportDiagnostic(Diagnostic.Create(NeuronAliasIsGenerated, neuron.Location, neuron.FullName)); valid = false; } @@ -86,9 +78,7 @@ private static void EmitNeuron(SourceProductionContext production, NeuronModel n return; } - production.AddSource( - $"{neuron.FullName}.NeuronIdentity.g.cs", - NeuronIdentity(neuron)); + production.AddSource($"{neuron.FullName}.NeuronIdentity.g.cs", NeuronIdentity(neuron)); } private static string NeuronIdentity(NeuronModel neuron) diff --git a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs index cc1b716f0..35c630118 100644 --- a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs +++ b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegation.cs @@ -9,11 +9,7 @@ namespace DigitalBrain.Kernel; [Alias("db.capability-delegation")] public sealed class CapabilityDelegation { - internal CapabilityDelegation( - Guid identity, - SynapseDelivery request, - GrainId delegateSource, - OwnerId owner) + internal CapabilityDelegation(Guid identity, SynapseDelivery request, GrainId delegateSource, OwnerId owner) { Identity = identity; Request = request; @@ -51,10 +47,7 @@ internal bool Matches(CapabilityDelegation other) && expected == actual; } - internal void RequireMatches( - GrainId? actualSource, - GrainId actualTarget, - MethodInfo? actualMethod) + internal void RequireMatches(GrainId? actualSource, GrainId actualTarget, MethodInfo? actualMethod) { if (Request.Synapse is not CapabilityRequested requested || actualSource is null diff --git a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs index 94af7edfe..aa79daa3b 100644 --- a/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs +++ b/src/core/kernel/DigitalBrain/Capabilities/CapabilityDelegationState.cs @@ -2,9 +2,7 @@ namespace DigitalBrain.Kernel; [GenerateSerializer] [Alias("db.kernel.capability-delegation-state")] -internal sealed class CapabilityDelegationState( - CapabilityDelegation delegation, - CapabilityDelegationStatus status) +internal sealed class CapabilityDelegationState(CapabilityDelegation delegation, CapabilityDelegationStatus status) { [Id(0)] internal CapabilityDelegation Delegation { get; } = delegation; diff --git a/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs b/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs index 94a8a4b9f..24813345a 100644 --- a/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs +++ b/src/core/kernel/DigitalBrain/Capabilities/CapabilityRequestContext.cs @@ -19,14 +19,10 @@ internal static RedeemedCapabilityDelegation? CurrentRedeemedDelegation internal static Task InvokeAsync(SynapseDelivery delivery, Func invoke) => InvokeWithAsync(delivery, invoke); - internal static Task InvokeAsync( - CapabilityDelegation delegation, - Func> invoke) + internal static Task InvokeAsync(CapabilityDelegation delegation, Func> invoke) => InvokeWithLocalDelegationAsync(delegation, invoke); - internal static Task InvokeRedeemedAsync( - CapabilityDelegation delegation, - Func invoke) + internal static Task InvokeRedeemedAsync(CapabilityDelegation delegation, Func invoke) => InvokeWithAsync(new RedeemedCapabilityDelegation(delegation), invoke); private static async Task InvokeWithLocalDelegationAsync( diff --git a/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs b/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs index d351b5cd5..9f31275b7 100644 --- a/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs +++ b/src/core/kernel/DigitalBrain/Filters/IncomingReificationFilter.cs @@ -61,10 +61,7 @@ public async Task Invoke(IIncomingGrainCallContext context) if (redeemed is not null) { - redeemed.Delegation.RequireMatches( - context.SourceId, - context.TargetId, - context.InterfaceMethod); + redeemed.Delegation.RequireMatches(context.SourceId, context.TargetId, context.InterfaceMethod); delivery = redeemed.Delegation.Request; delegatedSource = redeemed.Delegation.DelegateSource; } @@ -74,10 +71,7 @@ public async Task Invoke(IIncomingGrainCallContext context) delegatedSource = null; } - var turn = await target.BeginIncomingCapabilityRequestAsync( - delivery, - context.SourceId, - delegatedSource); + var turn = await target.BeginIncomingCapabilityRequestAsync(delivery, context.SourceId, delegatedSource); try { diff --git a/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs b/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs index 579221f55..d1ff8802d 100644 --- a/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs +++ b/src/core/kernel/DigitalBrain/Filters/OutgoingReificationFilter.cs @@ -39,24 +39,18 @@ public async Task Invoke(IOutgoingGrainCallContext context) } catch (NeuronAuthorizationException) when (caller.Id.Owner != target.Owner) { - await caller.RecordCapabilityOutcomeAsync( - CapabilityOutcome.Rejected, - request); + await caller.RecordCapabilityOutcomeAsync(CapabilityOutcome.Rejected, request); throw; } catch { - await caller.RecordCapabilityOutcomeAsync( - CapabilityOutcome.Failed, - request); + await caller.RecordCapabilityOutcomeAsync(CapabilityOutcome.Failed, request); throw; } - await caller.RecordCapabilityOutcomeAsync( - CapabilityOutcome.Completed, - request); + await caller.RecordCapabilityOutcomeAsync(CapabilityOutcome.Completed, request); } [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -75,10 +69,7 @@ private static async Task InvokeDelegatedAsync(IOutgoingGrainCallContext context "The delegated call's inherited Orleans source does not match its activation source."); } - delegation.RequireMatches( - context.SourceId, - context.TargetId, - context.InterfaceMethod); + delegation.RequireMatches(context.SourceId, context.TargetId, context.InterfaceMethod); var authority = context.SourceContext!.ActivationServices .GetRequiredService() diff --git a/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs b/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs index 2f4c2ee21..2f0ac290d 100644 --- a/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs +++ b/src/core/kernel/DigitalBrain/Filters/OwnerBoundCallFilter.cs @@ -3,8 +3,7 @@ namespace DigitalBrain.Kernel; -internal sealed class OwnerBoundCallFilter( - IEnumerable additionalReminderSources) : +internal sealed class OwnerBoundCallFilter(IEnumerable additionalReminderSources) : IIncomingGrainCallFilter { public Task Invoke(IIncomingGrainCallContext context) @@ -64,22 +63,15 @@ private static bool IsReminderCall(IIncomingGrainCallContext context) => context.InterfaceMethod?.DeclaringType == typeof(IRemindable) && context.InterfaceMethod?.Name == nameof(IRemindable.ReceiveReminder); - private static bool IsOutboxDrainCall( - IIncomingGrainCallContext context) + private static bool IsOutboxDrainCall(IIncomingGrainCallContext context) => context.InterfaceMethod?.DeclaringType == typeof(IOutboxDrain) && context.InterfaceMethod?.Name == nameof(IOutboxDrain.Drain); - private static void RequireDedicatedWakeupForTarget( - IIncomingGrainCallContext context) + private static void RequireDedicatedWakeupForTarget(IIncomingGrainCallContext context) { if (context.SourceId is not { } source - || !string.Equals( - source.Type.ToString(), - OutboxWakeup.GrainTypeName, - StringComparison.Ordinal) - || !OutboxWakeup.TryParseTarget( - source.Key.ToString(), - out var encodedTarget) + || !string.Equals(source.Type.ToString(), OutboxWakeup.GrainTypeName, StringComparison.Ordinal) + || !OutboxWakeup.TryParseTarget(source.Key.ToString(), out var encodedTarget) || encodedTarget.ToGrainId() != context.TargetId) { throw new NeuronAuthorizationException( @@ -89,10 +81,7 @@ private static void RequireDedicatedWakeupForTarget( private bool IsTrustedReminderProvider(GrainId? source) => source is { } identified - && (string.Equals( - identified.Type.ToString(), - ReminderGrainServiceType, - StringComparison.Ordinal) + && (string.Equals(identified.Type.ToString(), ReminderGrainServiceType, StringComparison.Ordinal) || IsAdditionalTrustedSource(identified)); private bool IsAdditionalTrustedSource(GrainId source) diff --git a/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs b/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs index 59ef153e3..a1623b032 100644 --- a/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs +++ b/src/core/kernel/DigitalBrain/Hosting/DigitalBrainRuntime.cs @@ -59,9 +59,7 @@ public static IReadOnlySet Add( return selectedModules; } - private static HashSet SelectModules( - ISiloBuilder builder, - IReadOnlyCollection availableModules) + private static HashSet SelectModules(ISiloBuilder builder, IReadOnlyCollection availableModules) { var hostContext = builder.Services .LastOrDefault(descriptor => descriptor.ServiceType == typeof(HostBuilderContext)) diff --git a/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs b/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs index d76fd497e..f69ddb86c 100644 --- a/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs +++ b/src/core/kernel/DigitalBrain/Hosting/PinToSiloDirector.cs @@ -8,10 +8,7 @@ internal sealed class PinToSiloDirector(ISiloMetadataCache metadata) : IPlacemen { internal const string SiloLabelKey = "db.silo"; - public IEnumerable Filter( - PlacementFilterStrategy strategy, - PlacementTarget target, - IEnumerable silos) + public IEnumerable Filter(PlacementFilterStrategy strategy, PlacementTarget target, IEnumerable silos) { ArgumentNullException.ThrowIfNull(strategy); diff --git a/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs b/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs index 771abe8c4..8c3d90ebe 100644 --- a/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs +++ b/src/core/kernel/DigitalBrain/Neuron/DigitalBrainNeuron.cs @@ -13,8 +13,7 @@ internal sealed class DigitalBrainNeuron : Neuron, IDigitalBrainNeuron public DigitalBrainNeuron() { - _activationPublished = ServiceProvider.GetRequiredKeyedService>( - ActivationPublishedName); + _activationPublished = ServiceProvider.GetRequiredKeyedService>(ActivationPublishedName); } public async Task Activate() diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs index f99a10672..369741b0d 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Delegation.cs @@ -88,12 +88,7 @@ async Task ICapabilityDelegationAuthority.FinishAsync(CapabilityDelegation deleg ? (Synapse)new CapabilityCompleted(delegation.Request.SynapseId) : new CapabilityFailed(delegation.Request.SynapseId); var sequence = _outgoing.NextSequence + _firedWhileHandling.Count; - var delivery = SynapseDelivery.Create( - fact, - Id, - sequence, - delegation.Request, - TimeProvider); + var delivery = SynapseDelivery.Create(fact, Id, sequence, delegation.Request, TimeProvider); var delegationCheckpoint = SnapshotDelegations(); var outgoingCheckpoint = _outgoing.Checkpoint(); @@ -101,9 +96,7 @@ async Task ICapabilityDelegationAuthority.FinishAsync(CapabilityDelegation deleg { FlushOutgoing(); _outgoing.Append(delivery); - _delegations[delegation.Identity] = _delegationStates.SerializeToArray(new( - state.Delegation, - terminal)); + _delegations[delegation.Identity] = _delegationStates.SerializeToArray(new(state.Delegation, terminal)); _delegationConsumed.RemoveAt(consumedIndex); _delegationTerminals.Add(delegation.Identity); await CommitAsync(CancellationToken.None); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs index f94beb43f..537072891 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.Incoming.cs @@ -62,12 +62,7 @@ internal async Task BeginIncomingCapabilityRequestAsync( _handling = delivery; _handlingDepth = DeliveryPolicy.InboundDepth(); - _turnCheckpoint = new( - _outbox.Count, - _handled.Count, - InboundCommitted: true, - _incoming.Checkpoint(), - _outgoing.Checkpoint()); + _turnCheckpoint = new(_outbox.Count, _handled.Count, InboundCommitted: true, _incoming.Checkpoint(), _outgoing.Checkpoint()); _turnRollbacks.Clear(); return turn; @@ -86,9 +81,7 @@ internal async Task CompleteIncomingCapabilityRequestAsync(CapabilityTurn turn) internal void FailIncomingCapabilityRequest(CapabilityTurn turn) { - Discard( - _outbox, - _turnCheckpoint?.CommittedOutbox ?? turn.CommittedOutbox); + Discard(_outbox, _turnCheckpoint?.CommittedOutbox ?? turn.CommittedOutbox); _outgoing.Restore(_turnCheckpoint?.Outgoing ?? turn.Outgoing); RollbackTurnState(); _firedWhileHandling.Clear(); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs index 16a8b1679..839105c38 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Capability.cs @@ -20,11 +20,7 @@ protected void EnlistTurnRollback(Action rollback) _turnRollbacks.Add(rollback); } - protected Task DelegateCapabilityAsync( - GrainId delegateSource, - NeuronId target, - Type contract, - string method) + protected Task DelegateCapabilityAsync(GrainId delegateSource, NeuronId target, Type contract, string method) => DelegateCapabilityAsync(_handling, delegateSource, target, contract, method); private async Task DelegateCapabilityAsync( @@ -67,11 +63,7 @@ private async Task DelegateCapabilityAsync( sequence, causation, TimeProvider); - var delegation = new CapabilityDelegation( - Guid.NewGuid(), - request, - delegateSource, - Id.Owner); + var delegation = new CapabilityDelegation(Guid.NewGuid(), request, delegateSource, Id.Owner); var delegationCheckpoint = SnapshotDelegations(); var outgoingCheckpoint = _outgoing.Checkpoint(); @@ -81,11 +73,7 @@ private async Task DelegateCapabilityAsync( StageInboundCause(); FlushOutgoing(); _outgoing.Append(request); - _delegations.Add( - delegation.Identity, - _delegationStates.SerializeToArray(new( - delegation, - CapabilityDelegationStatus.Issued))); + _delegations.Add(delegation.Identity, _delegationStates.SerializeToArray(new(delegation, CapabilityDelegationStatus.Issued))); await CommitAsync(CancellationToken.None); } catch @@ -134,10 +122,7 @@ private SynapseDelivery CurrentCapabilityRequestFrom(NeuronId expectedCaller) return delivery; } - internal async Task BeginCapabilityRequestAsync( - string contract, - string method, - NeuronId target) + internal async Task BeginCapabilityRequestAsync(string contract, string method, NeuronId target) { ArgumentException.ThrowIfNullOrWhiteSpace(contract); ArgumentException.ThrowIfNullOrWhiteSpace(method); @@ -161,9 +146,7 @@ internal async Task BeginCapabilityRequestAsync( return delivery; } - internal async Task RecordCapabilityOutcomeAsync( - CapabilityOutcome outcome, - SynapseDelivery request) + internal async Task RecordCapabilityOutcomeAsync(CapabilityOutcome outcome, SynapseDelivery request) { ArgumentNullException.ThrowIfNull(request); @@ -172,17 +155,11 @@ internal async Task RecordCapabilityOutcomeAsync( CapabilityOutcome.Completed => new CapabilityCompleted(request.SynapseId), CapabilityOutcome.Failed => new CapabilityFailed(request.SynapseId), CapabilityOutcome.Rejected => new CapabilityRejected(request.SynapseId), - _ => throw new ArgumentOutOfRangeException( - nameof(outcome)), + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), }; var sequence = _outgoing.NextSequence + _firedWhileHandling.Count; - var delivery = SynapseDelivery.Create( - fact, - Id, - sequence, - request, - TimeProvider); + var delivery = SynapseDelivery.Create(fact, Id, sequence, request, TimeProvider); FlushOutgoing(); _outgoing.Append(delivery); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs index 190359aec..8b96863fa 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Concurrency.cs @@ -2,11 +2,7 @@ namespace DigitalBrain.Kernel; public abstract partial class Neuron { - protected new IDisposable RegisterTimer( - Func callback, - object state, - TimeSpan dueTime, - TimeSpan period) + protected new IDisposable RegisterTimer(Func callback, object state, TimeSpan dueTime, TimeSpan period) => throw new InvalidOperationException( $"{nameof(RegisterTimer)} creates interleaving callbacks, but neurons require serialized turns."); } diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs index 568afeb7a..9c5f6bb6d 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Lifecycle.cs @@ -45,12 +45,7 @@ public async Task Deliver(SynapseDelivery delivery) _handlingDepth = DeliveryPolicy.InboundDepth(); var previousCheckpoint = _turnCheckpoint; - _turnCheckpoint = new( - _outbox.Count, - _handled.Count, - InboundCommitted: false, - _incoming.Checkpoint(), - _outgoing.Checkpoint()); + _turnCheckpoint = new(_outbox.Count, _handled.Count, InboundCommitted: false, _incoming.Checkpoint(), _outgoing.Checkpoint()); _firedWhileHandling.Clear(); _turnRollbacks.Clear(); diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs index dbc2ea523..3b3b92bf2 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Messaging.cs @@ -35,10 +35,7 @@ protected async Task EmitAsync(Synapse synapse) await FireAsync(synapse, [.. receivers], correlation); } - private Task FireAsync( - Synapse synapse, - NeuronId[] receivers, - CorrelationId? correlation = null) + private Task FireAsync(Synapse synapse, NeuronId[] receivers, CorrelationId? correlation = null) => FireAsync(synapse, receivers, _handling, correlation); private async Task FireAsync( @@ -49,13 +46,7 @@ private async Task FireAsync( { var sequence = _outgoing.NextSequence + (_handling is null ? 0 : _firedWhileHandling.Count); - var delivery = SynapseDelivery.Create( - Snapshot(synapse), - Id, - sequence, - causation, - TimeProvider, - correlation); + var delivery = SynapseDelivery.Create(Snapshot(synapse), Id, sequence, causation, TimeProvider, correlation); if (_handling is null) { @@ -68,8 +59,7 @@ private async Task FireAsync( if (receivers.Length > 0) { - _outbox.Add(_entries.SerializeToArray( - new OutboxEntry(delivery, receivers, _handlingDepth + 1, Attempts: 0))); + _outbox.Add(_entries.SerializeToArray(new OutboxEntry(delivery, receivers, _handlingDepth + 1, Attempts: 0))); } if (_handling is null) diff --git a/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs b/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs index ee57a2da8..600339381 100644 --- a/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs +++ b/src/core/kernel/DigitalBrain/Neuron/Neuron.Turns.cs @@ -133,16 +133,12 @@ private void MakeRoomForDelegation() { while (_delegations.Count >= MaximumRememberedDelegations) { - if (TryEvictOldest( - _delegationTerminals, - ProtectedTerminalDelegations)) + if (TryEvictOldest(_delegationTerminals, ProtectedTerminalDelegations)) { continue; } - if (TryEvictOldest( - _delegationConsumed, - ProtectedConsumedDelegations)) + if (TryEvictOldest(_delegationConsumed, ProtectedConsumedDelegations)) { continue; } @@ -157,9 +153,7 @@ private void MakeRoomForDelegation() } } - private bool TryEvictOldest( - IDurableList retentionOrder, - int protectedDelegations) + private bool TryEvictOldest(IDurableList retentionOrder, int protectedDelegations) { if (retentionOrder.Count <= protectedDelegations) { diff --git a/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs b/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs index 65bb0065c..e874cc8ed 100644 --- a/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs +++ b/src/core/kernel/DigitalBrain/Outbox/OutboxWakeup.cs @@ -16,10 +16,7 @@ internal sealed class OutboxWakeup : TimeSpan.FromMinutes(1); public async Task Arm() - => _ = await this.RegisterOrUpdateReminder( - ReminderName, - RetryCadence, - RetryCadence); + => _ = await this.RegisterOrUpdateReminder(ReminderName, RetryCadence, RetryCadence); public async Task Disarm() { @@ -29,14 +26,9 @@ public async Task Disarm() } } - async Task IRemindable.ReceiveReminder( - string reminderName, - TickStatus status) + async Task IRemindable.ReceiveReminder(string reminderName, TickStatus status) { - if (!string.Equals( - reminderName, - ReminderName, - StringComparison.Ordinal)) + if (!string.Equals(reminderName, ReminderName, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Outbox wakeup '{this.GetPrimaryKeyString()}' does not own reminder '{reminderName}'."); @@ -47,9 +39,7 @@ await GrainFactory .Drain(); } - internal static bool TryParseTarget( - string encoded, - out NeuronId target) + internal static bool TryParseTarget(string encoded, out NeuronId target) { target = default; @@ -66,9 +56,7 @@ internal static bool TryParseTarget( try { - target = NeuronId.FromGrainKey( - encoded[..separator], - encoded[(separator + 1)..]); + target = NeuronId.FromGrainKey(encoded[..separator], encoded[(separator + 1)..]); return true; } catch (ArgumentException) diff --git a/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs b/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs index 6762dd55a..9f53cb9f0 100644 --- a/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs +++ b/src/core/kernel/DigitalBrain/ReminderSourceAllowlist.cs @@ -2,8 +2,7 @@ namespace DigitalBrain.Kernel; -internal sealed class ReminderSourceAllowlist( - IEnumerable sourceTypes) +internal sealed class ReminderSourceAllowlist(IEnumerable sourceTypes) { private readonly FrozenSet _sourceTypes = sourceTypes.ToFrozenSet(StringComparer.Ordinal); diff --git a/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs b/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs index 316601748..7ab1fc760 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Aspire.Hosting/McpHosting.cs @@ -17,9 +17,7 @@ internal sealed record McpProviderHostingDefinition( internal static class McpProviderHosting { - internal static void Register( - DigitalBrainModuleBuilder module, - McpProviderHostingDefinition definition) + internal static void Register(DigitalBrainModuleBuilder module, McpProviderHostingDefinition definition) where TModule : class, IModule, new() { ArgumentNullException.ThrowIfNull(module); @@ -36,10 +34,10 @@ internal static void Register( } projection.Add(definition, application.Register(definition)); + module.ConfigureFeature(definition.Key); } - private static McpApplicationParameters GetOrAddApplicationParameters( - IDistributedApplicationBuilder builder) + private static McpApplicationParameters GetOrAddApplicationParameters(IDistributedApplicationBuilder builder) { var existing = builder.Services .LastOrDefault(descriptor => descriptor.ServiceType == typeof(McpApplicationParameters)) @@ -137,17 +135,13 @@ private sealed class McpBrainProjection : DigitalBrainModuleProjection new(StringComparer.Ordinal); private readonly IResourceBuilder _authorizationMode; - internal McpBrainProjection( - DigitalBrainBuilder brain, - IResourceBuilder authorizationMode) + internal McpBrainProjection(DigitalBrainBuilder brain, IResourceBuilder authorizationMode) { _brain = brain; _authorizationMode = authorizationMode; } - internal void Add( - McpProviderHostingDefinition definition, - McpProviderParameters parameters) + internal void Add(McpProviderHostingDefinition definition, McpProviderParameters parameters) { if (_providers.ContainsKey(definition.Key)) { @@ -162,16 +156,11 @@ public override void Apply(IResourceBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - builder.WithEnvironment( - "DigitalBrain__Integrations__Mcp__AuthorizationMode", - _authorizationMode); + builder.WithEnvironment("DigitalBrain__Integrations__Mcp__AuthorizationMode", _authorizationMode); foreach (var provider in _providers.Values) { - var root = provider.Definition.ConfigurationRoot.Replace( - ":", - "__", - StringComparison.Ordinal); + var root = provider.Definition.ConfigurationRoot.Replace(":", "__", StringComparison.Ordinal); builder .WithEnvironment($"{root}__ClientId", provider.ClientId) .WithEnvironment($"{root}__RedirectUri", provider.RedirectUri); diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs index a21366d95..7bff34088 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/AccountEnrichmentProcessSample.cs @@ -46,9 +46,7 @@ await shell.Reference.Open(new OpenScene( Assert.DoesNotContain(expectedDescription, opened.Synapse.Title, StringComparison.Ordinal); } - private static async Task RunEnrichmentToCompletionAsync( - TestBrain test, - CancellationToken cancellationToken) + private static async Task RunEnrichmentToCompletionAsync(TestBrain test, CancellationToken cancellationToken) { var expectedDescription = IntegrationsFixture.SampleEnrichmentDescription; test.Mcp().Catalog( @@ -61,14 +59,11 @@ private static async Task RunEnrichmentToCompletionAsync( test.Mcp().Catalog( IntegrationsFixture.SalesforceServerKey, AdmittedMcpTools.SalesforceUpdateAccount(), - AdmittedMcpTools.SalesforceSoqlQuery( - IntegrationsFixture.SampleAccountId, - expectedDescription)); + AdmittedMcpTools.SalesforceSoqlQuery(IntegrationsFixture.SampleAccountId, expectedDescription)); var enrichment = test.Neuron("enricher"); var commandId = CommandId.New(); - var proposedWait = enrichment.Outgoing.NextAsync( - cancellationToken); + var proposedWait = enrichment.Outgoing.NextAsync(cancellationToken); await test.Client.SendAsync( "enricher", diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs index 080e5c4e9..aa9684c74 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/GmailReadMessage.cs @@ -40,9 +40,7 @@ public async Task ReadMessageFailsClosedWhenEdgeIsNotAdmitted() { var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); - test.Mcp().Catalog( - IntegrationsFixture.GmailServerKey, - AdmittedMcpTools.GmailGetMessageWithIncompatibleAnnotations()); + test.Mcp().Catalog(IntegrationsFixture.GmailServerKey, AdmittedMcpTools.GmailGetMessageWithIncompatibleAnnotations()); var driver = test.Neuron("gmail-refuse"); var failure = await Assert.ThrowsAnyAsync(() => @@ -141,9 +139,7 @@ public async Task ReadMessageRejectsToolError() { var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); - test.Mcp().Catalog( - IntegrationsFixture.GmailServerKey, - AdmittedMcpTools.GmailGetMessageWithToolError()); + test.Mcp().Catalog(IntegrationsFixture.GmailServerKey, AdmittedMcpTools.GmailGetMessageWithToolError()); var driver = test.Neuron("gmail-tool-error"); var failure = await Assert.ThrowsAsync(() => diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs index ccf2cfb78..eec225e7c 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/GoogleContracts.cs @@ -42,9 +42,7 @@ public void ReadMessageIsUnsuffixedAliasedAndReturnsGmailMessage() Assert.All(methods, method => { Assert.DoesNotContain("Async", method.Name, StringComparison.Ordinal); - Assert.Equal( - method.Name, - method.GetCustomAttribute()?.Alias); + Assert.Equal(method.Name, method.GetCustomAttribute()?.Alias); Assert.Equal(typeof(Task), method.ReturnType); }); diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs index 0620eeb65..e56932528 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsFixture.cs @@ -32,19 +32,11 @@ public static NeuronId SessionOf(TestBrain test) return ISessionNeuron.ForOwner(test.Client.Owner); } - public static SalesforceMutationApproval Approval( - TestBrain test, - CommandId commandId, - string fingerprint) + public static SalesforceMutationApproval Approval(TestBrain test, CommandId commandId, string fingerprint) { ArgumentNullException.ThrowIfNull(test); ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); - return new( - Guid.NewGuid(), - commandId, - fingerprint, - SessionOf(test), - test.Clock.UtcNow); + return new(Guid.NewGuid(), commandId, fingerprint, SessionOf(test), test.Clock.UtcNow); } protected override void Configure(DigitalBrainTestBuilder brain) diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs index b72b1a38a..40e05d655 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/IntegrationsHarness.cs @@ -9,10 +9,7 @@ namespace DigitalBrain.Integrations.Tests; public partial interface IIntegrationDriver : INeuron { [Alias(nameof(ReadGmailMessage))] - Task ReadGmailMessage( - string account, - string messageId, - CancellationToken cancellationToken); + Task ReadGmailMessage(string account, string messageId, CancellationToken cancellationToken); [Alias(nameof(ProposeSalesforceAccountDescription))] Task ProposeSalesforceAccountDescription( @@ -38,19 +35,14 @@ internal sealed class IntegrationDriver : IIntegrationDriver, IHandle { - public Task HandleAsync( - SalesforceMutationApproval synapse, - CancellationToken cancellationToken) + public Task HandleAsync(SalesforceMutationApproval synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } - public Task ReadGmailMessage( - string account, - string messageId, - CancellationToken cancellationToken) + public Task ReadGmailMessage(string account, string messageId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(account); ArgumentException.ThrowIfNullOrWhiteSpace(messageId); @@ -65,12 +57,7 @@ public Task ProposeSalesforceAccountDescri string accountId, string description, CancellationToken cancellationToken) - => Salesforce().ProposeAccountDescription( - commandId, - Id, - accountId, - description, - cancellationToken); + => Salesforce().ProposeAccountDescription(commandId, Id, accountId, description, cancellationToken); public async Task ApproveSalesforceWithStoredEvidence( SalesforceMutationApproval approval, @@ -78,10 +65,7 @@ public async Task ApproveSalesforceWithSto { ArgumentNullException.ThrowIfNull(approval); var evidence = await ApprovalEvidenceAsync(approval); - return await Salesforce().ApproveAccountDescription( - approval, - evidence, - cancellationToken); + return await Salesforce().ApproveAccountDescription(approval, evidence, cancellationToken); } public async Task ApproveSalesforceWithMismatchedEvidence( @@ -92,18 +76,14 @@ public async Task ApproveSalesforceWithMismatchedEvidence( ArgumentNullException.ThrowIfNull(approval); ArgumentNullException.ThrowIfNull(recordedEvidence); var evidence = await ApprovalEvidenceAsync(recordedEvidence); - _ = await Salesforce().ApproveAccountDescription( - approval, - evidence, - cancellationToken); + _ = await Salesforce().ApproveAccountDescription(approval, evidence, cancellationToken); } private ISalesforce Salesforce() => GrainFactory.GetGrain( NeuronId.For(Id.Owner, IntegrationsFixture.SalesforceServerKey).ToGrainId()); - private async Task ApprovalEvidenceAsync( - SalesforceMutationApproval approval) + private async Task ApprovalEvidenceAsync(SalesforceMutationApproval approval) { var incoming = await ReadJournal(JournalKind.Incoming, afterSequence: 0); return incoming.Delta.FirstOrDefault(delivery => diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs index 2a2bac7d0..656dc7df0 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdge.cs @@ -61,27 +61,17 @@ internal static class AdmittedMcpTools OpenWorldHint = false, }; - internal static McpServerTool GmailGetMessage( - string id, - string subject, - string sender, - string plaintextBody) - => Fixed( - GmailGetMessageProtocolTool(ReadOnlyAdmitted), - _ => Structured(new { id, subject, sender, plaintextBody })); + internal static McpServerTool GmailGetMessage(string id, string subject, string sender, string plaintextBody) + => Fixed(GmailGetMessageProtocolTool(ReadOnlyAdmitted), _ => Structured(new { id, subject, sender, plaintextBody })); internal static McpServerTool GmailGetMessageWithPayload(object payload) { ArgumentNullException.ThrowIfNull(payload); - return Fixed( - GmailGetMessageProtocolTool(ReadOnlyAdmitted), - _ => Structured(payload)); + return Fixed(GmailGetMessageProtocolTool(ReadOnlyAdmitted), _ => Structured(payload)); } internal static McpServerTool GmailGetMessageWithToolError() - => Fixed( - GmailGetMessageProtocolTool(ReadOnlyAdmitted), - _ => new CallToolResult { IsError = true }); + => Fixed(GmailGetMessageProtocolTool(ReadOnlyAdmitted), _ => new CallToolResult { IsError = true }); internal static McpServerTool GmailGetMessageWithIncompatibleAnnotations() => Fixed( @@ -142,23 +132,15 @@ private static Tool GmailGetMessageProtocolTool(ToolAnnotations annotations) Annotations = annotations, }; - private static JsonElement ObjectSchema( - params (string Name, object Definition)[] properties) - => ObjectSchema( - required: properties.Select(property => property.Name).ToArray(), - properties); + private static JsonElement ObjectSchema(params (string Name, object Definition)[] properties) + => ObjectSchema(required: properties.Select(property => property.Name).ToArray(), properties); - private static JsonElement ObjectSchema( - string[]? required, - params (string Name, object Definition)[] properties) + private static JsonElement ObjectSchema(string[]? required, params (string Name, object Definition)[] properties) { var shape = new Dictionary { [Type] = Object, - [Properties] = properties.ToDictionary( - property => property.Name, - property => property.Definition, - StringComparer.Ordinal), + [Properties] = properties.ToDictionary(property => property.Name, property => property.Definition, StringComparer.Ordinal), }; if (required is not null) { @@ -174,9 +156,7 @@ private static CallToolResult Structured(object payload) StructuredContent = JsonSerializer.SerializeToElement(payload), }; - private static FixedSchemaTool Fixed( - Tool protocolTool, - Func, CallToolResult> invoke) + private static FixedSchemaTool Fixed(Tool protocolTool, Func, CallToolResult> invoke) => new(protocolTool, invoke); private sealed class FixedSchemaTool( diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs index 59cb91b7c..5416874fe 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/McpEdgeHarness.cs @@ -13,10 +13,7 @@ internal static class McpEdgeExtensions internal static void ConfigureMcpEdge(this DigitalBrainTestBuilder builder) { var script = new McpEdgeScript(); - builder.ConfigureMcpSessionFactory( - new ScriptedMcpSessionFactory(script), - script, - static edge => edge.Reset()); + builder.ConfigureMcpSessionFactory(new ScriptedMcpSessionFactory(script), script, static edge => edge.Reset()); } internal static McpEdgeScript Mcp(this TestBrain brain) @@ -92,12 +89,8 @@ public async ValueTask OpenAsync( var tools = script.ToolsFor(server.Key); var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var serverTransport = new StreamServerTransport( - clientToServer.Reader.AsStream(), - serverToClient.Writer.AsStream()); - var clientTransport = new StreamClientTransport( - clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream()); + var serverTransport = new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()); + var clientTransport = new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()); var mcpServer = McpServer.Create( serverTransport, @@ -118,10 +111,7 @@ public async ValueTask OpenAsync( private const string ScriptedServerVersion = "test"; - private sealed class ScriptedMcpSession( - McpClient client, - McpServer server, - Task run) : IMcpClientSession + private sealed class ScriptedMcpSession(McpClient client, McpServer server, Task run) : IMcpClientSession { public McpClient Client { get; } = client; diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs index 92d029e14..487e0fd19 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/McpOAuthCallbacks.cs @@ -84,16 +84,12 @@ public void MissingCallbackUriIsRejected() BindingFlags.NonPublic | BindingFlags.Static)!; var exception = Assert.Throws(() => validate.Invoke( - null, - [new Uri("https://provider.example/authorize?state=expected"), RedirectUri(), null])); + null, [new Uri("https://provider.example/authorize?state=expected"), RedirectUri(), null])); Assert.IsType(exception.InnerException); } - private static Task Authorize( - Uri redirectUri, - Action launched, - CancellationToken cancellationToken) + private static Task Authorize(Uri redirectUri, Action launched, CancellationToken cancellationToken) { var core = typeof(LocalLoopbackMcpAuthorizationCallback).GetMethod( "AuthorizeAsyncCore", @@ -104,10 +100,7 @@ public void MissingCallbackUriIsRejected() [new Uri("https://provider.example/authorize?state=expected"), redirectUri, start, cancellationToken])!; } - private static async Task SendAsync( - Uri uri, - string query, - CancellationToken cancellationToken) + private static async Task SendAsync(Uri uri, string query, CancellationToken cancellationToken) { using var client = new HttpClient(); return await client.GetAsync(new Uri(uri + query), cancellationToken); diff --git a/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs b/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs index 7e3549db8..908fb7c9a 100644 --- a/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs +++ b/src/core/mcp/DigitalBrain.Mcp.Tests/SalesforceMutation.cs @@ -50,9 +50,7 @@ public async Task ProposeRejectsCommandIdReuseWithDifferentContent() Assert.Contains("fingerprint", failure.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, test.Mcp().SessionCount); - Assert.Equal( - proposed, - await ProposeAsync(driver, commandId, description, cancellationToken)); + Assert.Equal(proposed, await ProposeAsync(driver, commandId, description, cancellationToken)); } [Fact(DisplayName = @@ -71,10 +69,7 @@ public async Task ApproveRejectsMismatchedEvidenceWithoutMcp() var mismatched = recorded with { ApprovalId = Guid.NewGuid() }; await Assert.ThrowsAsync(() => - driver.Reference.ApproveSalesforceWithMismatchedEvidence( - mismatched, - recorded, - cancellationToken)); + driver.Reference.ApproveSalesforceWithMismatchedEvidence(mismatched, recorded, cancellationToken)); Assert.Equal(0, test.Mcp().SessionCount); Assert.Equal( @@ -93,16 +88,11 @@ public async Task ApproveRejectsMismatchedFingerprintWithoutMcp() var description = IntegrationsFixture.SampleEnrichmentDescription; var proposed = await ProposeAsync(driver, commandId, description, cancellationToken); - var wrongFingerprint = IntegrationsFixture.Approval( - test, - commandId, - proposed.Fingerprint + "-tampered"); + var wrongFingerprint = IntegrationsFixture.Approval(test, commandId, proposed.Fingerprint + "-tampered"); await DeliverApprovalAsync(test, driver, wrongFingerprint, cancellationToken); var failure = await Assert.ThrowsAsync(() => - driver.Reference.ApproveSalesforceWithStoredEvidence( - wrongFingerprint, - cancellationToken)); + driver.Reference.ApproveSalesforceWithStoredEvidence(wrongFingerprint, cancellationToken)); Assert.Contains("fingerprint", failure.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, test.Mcp().SessionCount); @@ -126,9 +116,7 @@ public async Task ApproveCompletesThroughScriptedMcpEdge() var approval = IntegrationsFixture.Approval(test, commandId, proposed.Fingerprint); await DeliverApprovalAsync(test, driver, approval, cancellationToken); - var completed = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var completed = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(SalesforceMutationState.Completed, completed.State); Assert.Equal(commandId, completed.CommandId); @@ -137,9 +125,7 @@ public async Task ApproveCompletesThroughScriptedMcpEdge() Assert.Equal(proposed.Fingerprint, completed.Fingerprint); Assert.True(test.Mcp().SessionCount >= 1); - var again = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var again = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(completed, again); } @@ -157,16 +143,12 @@ public async Task ApproveReturnsOutcomeUncertainWhenReconciliationCannotProveWri test.Mcp().Catalog( IntegrationsFixture.SalesforceServerKey, AdmittedMcpTools.SalesforceUpdateAccount(success: false), - AdmittedMcpTools.SalesforceSoqlQuery( - IntegrationsFixture.SampleAccountId, - UnprovenProviderDescription)); + AdmittedMcpTools.SalesforceSoqlQuery(IntegrationsFixture.SampleAccountId, UnprovenProviderDescription)); var approval = IntegrationsFixture.Approval(test, commandId, proposed.Fingerprint); await DeliverApprovalAsync(test, driver, approval, cancellationToken); - var uncertain = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var uncertain = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(SalesforceMutationState.OutcomeUncertain, uncertain.State); Assert.Equal(commandId, uncertain.CommandId); @@ -175,9 +157,7 @@ public async Task ApproveReturnsOutcomeUncertainWhenReconciliationCannotProveWri Assert.Equal(proposed.Fingerprint, uncertain.Fingerprint); Assert.True(test.Mcp().SessionCount >= 1); - var again = await driver.Reference.ApproveSalesforceWithStoredEvidence( - approval, - cancellationToken); + var again = await driver.Reference.ApproveSalesforceWithStoredEvidence(approval, cancellationToken); Assert.Equal(SalesforceMutationState.OutcomeUncertain, again.State); Assert.Equal(uncertain, again); } diff --git a/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj b/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj index decce357f..bbcf6e708 100644 --- a/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj +++ b/src/core/mcp/DigitalBrain.Mcp/DigitalBrain.Mcp.csproj @@ -7,6 +7,7 @@ + diff --git a/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs b/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs index 4ed23f039..22fbd5539 100644 --- a/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs +++ b/src/core/mcp/DigitalBrain.Mcp/DurableMcpTokenCache.cs @@ -26,9 +26,7 @@ internal sealed class DurableMcpTokenCache( return ValueTask.FromResult(tokens); } - public async ValueTask StoreTokensAsync( - TokenContainer tokens, - CancellationToken cancellationToken) + public async ValueTask StoreTokensAsync(TokenContainer tokens, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(tokens); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs b/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs index 5e3259a9f..f2177aad8 100644 --- a/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs +++ b/src/core/mcp/DigitalBrain.Mcp/HttpMcpClientSessionFactory.cs @@ -27,11 +27,7 @@ public async ValueTask OpenAsync( ArgumentNullException.ThrowIfNull(commit); ArgumentException.ThrowIfNullOrWhiteSpace(durableIdentity); - var tokens = new DurableMcpTokenCache( - tokenState, - commit, - protector, - $"mcp/oauth/{server.Key}/{durableIdentity}"); + var tokens = new DurableMcpTokenCache(tokenState, commit, protector, $"mcp/oauth/{server.Key}/{durableIdentity}"); var authorization = McpOAuthOptions.Create(server, configuration, tokens); var httpClient = httpClients.CreateClient(McpRuntime.HttpClientName); var transport = new HttpClientTransport( @@ -44,9 +40,7 @@ public async ValueTask OpenAsync( httpClient, loggerFactory: null, ownsHttpClient: true); - var client = await McpClient.CreateAsync( - transport, - cancellationToken: cancellationToken); + var client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken); return new OwnedMcpClientSession(client); } diff --git a/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs b/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs index d8f781894..f4e5d36c0 100644 --- a/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs +++ b/src/core/mcp/DigitalBrain.Mcp/LocalLoopbackMcpAuthorizationCallback.cs @@ -79,10 +79,7 @@ await RespondAsync( } } - private static AuthorizationResult? ValidateCallback( - Uri authorizationUri, - Uri redirectUri, - Uri? callbackUri) + private static AuthorizationResult? ValidateCallback(Uri authorizationUri, Uri redirectUri, Uri? callbackUri) { ArgumentNullException.ThrowIfNull(authorizationUri); ArgumentNullException.ThrowIfNull(redirectUri); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs b/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs index 51fe432de..a485b9c89 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpOAuthOptions.cs @@ -5,10 +5,7 @@ namespace DigitalBrain.Mcp; internal static class McpOAuthOptions { - internal static ClientOAuthOptions Create( - McpServerDefinition server, - IConfiguration configuration, - ITokenCache tokenCache) + internal static ClientOAuthOptions Create(McpServerDefinition server, IConfiguration configuration, ITokenCache tokenCache) { ArgumentNullException.ThrowIfNull(server); ArgumentNullException.ThrowIfNull(configuration); @@ -27,19 +24,13 @@ internal static ClientOAuthOptions Create( }; } - private static string? Optional( - IConfiguration configuration, - McpServerDefinition server, - string name) + private static string? Optional(IConfiguration configuration, McpServerDefinition server, string name) { var value = configuration[$"{server.ConfigurationRoot}:{name}"]; return !string.IsNullOrWhiteSpace(value) ? value : null; } - private static string Required( - IConfiguration configuration, - McpServerDefinition server, - string name) + private static string Required(IConfiguration configuration, McpServerDefinition server, string name) { var key = $"{server.ConfigurationRoot}:{name}"; var value = configuration[key]; @@ -50,10 +41,7 @@ private static string Required( $"{server.DisplayName} requires projected configuration '{key}'."); } - private static Uri RequiredUri( - IConfiguration configuration, - McpServerDefinition server, - string name) + private static Uri RequiredUri(IConfiguration configuration, McpServerDefinition server, string name) { var value = Required(configuration, server, name); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs b/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs index a7650a441..827ed2920 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpRuntime.cs @@ -23,19 +23,11 @@ internal async ValueTask RunAsync( ArgumentException.ThrowIfNullOrWhiteSpace(durableIdentity); ArgumentNullException.ThrowIfNull(callback); - await using var session = await sessions.OpenAsync( - server, - tokenState, - commit, - durableIdentity, - cancellationToken); + await using var session = await sessions.OpenAsync(server, tokenState, commit, durableIdentity, cancellationToken); return await callback(session.Client, cancellationToken); } - internal static JsonElement RequireStructuredContent( - CallToolResult result, - McpServerDefinition server, - string toolName) + internal static JsonElement RequireStructuredContent(CallToolResult result, McpServerDefinition server, string toolName) { ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(server); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs b/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs index 5346c5229..992ecdc9e 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpRuntimeHosting.cs @@ -15,7 +15,13 @@ internal static void Configure(IServiceCollection services, IConfiguration confi ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); - services.AddHttpClient(); + var http = services + .AddHttpClient( + McpRuntime.HttpClientName, + static client => client.Timeout = TimeSpan.FromMinutes(5)); +#pragma warning disable EXTEXP0001 + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 DurablePayloadProtectionHosting.Configure(services, configuration); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs b/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs index 6483c15b8..bb6760011 100644 --- a/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs +++ b/src/core/mcp/DigitalBrain.Mcp/McpToolFingerprint.cs @@ -39,9 +39,7 @@ private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element) { writer.WriteStartObject(); - foreach (var property in element.EnumerateObject().OrderBy( - property => property.Name, - StringComparer.Ordinal)) + foreach (var property in element.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal)) { writer.WritePropertyName(property.Name); WriteCanonical(writer, property.Value); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs index f10b2c0ed..de43ff33a 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityProbes.cs @@ -20,9 +20,7 @@ internal sealed class CapabilityCaller : ICapabilityCaller, IHandle { - public Task HandleAsync( - CapabilityPing synapse, - CancellationToken cancellationToken) + public Task HandleAsync(CapabilityPing synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs index e9a2c1419..f47619fa7 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/CapabilityReificationContracts.cs @@ -14,9 +14,7 @@ public async Task NeuronToNeuronCallReifiesRequestedAndCompletedFacts() var caller = test.Neuron(TestingScenario.CapabilityCaller); var target = test.Neuron(TestingScenario.CapabilityTarget); - await test.Client.SendAsync( - caller.Id.Name, - new CapabilityPing()); + await test.Client.SendAsync(caller.Id.Name, new CapabilityPing()); var stimulus = await caller.Incoming.NextAsync(cancellationToken); var requested = await caller.Outgoing.NextAsync(cancellationToken); @@ -42,9 +40,7 @@ await test.Client.SendAsync( Assert.Equal(requested.CorrelationId, completed.CorrelationId); Assert.Equal(caller.Id, completed.Caller); Assert.Equal(JournalKind.Outgoing, completed.Direction); - Assert.Empty(await caller.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await caller.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await caller.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await caller.Outgoing.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs index 383fb9fbe..101cca320 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/ClockContracts.cs @@ -88,9 +88,7 @@ public async Task ClockDrainsReentrantTimerWork() await probe.ScheduleAsync(1, 2, reentrantTimer: true, recurringTimer: false); await test.Clock.AdvanceAsync(TimeSpan.FromSeconds(2), cancellationToken); - Assert.Equal( - ["reminder", "timer", "reentrant-timer"], - await probe.EventsAsync()); + Assert.Equal(["reminder", "timer", "reentrant-timer"], await probe.EventsAsync()); } [Fact(DisplayName = "TestClock skips a cancelled timer while delivering a due reminder")] @@ -119,21 +117,14 @@ public async Task ClockRetainsDrainBound() var failure = await Assert.ThrowsAsync( () => test.Clock.AdvanceAsync(TimeSpan.FromSeconds(1026), cancellationToken)); - Assert.Contains( - "exceeded 1024 operations", - failure.InnerException!.Message, - StringComparison.Ordinal); + Assert.Contains("exceeded 1024 operations", failure.InnerException!.Message, StringComparison.Ordinal); } } [ClientEntryPoint] public partial interface IClockProbe : INeuron { - Task ScheduleAsync( - int reminderDueSeconds, - int timerDueSeconds, - bool reentrantTimer, - bool recurringTimer); + Task ScheduleAsync(int reminderDueSeconds, int timerDueSeconds, bool reentrantTimer, bool recurringTimer); Task CancelTimerAsync(); @@ -149,18 +140,12 @@ internal sealed class ClockProbe : Neuron, IClockProbe, IRemindable private ITimer? _timer; private bool _reentrantTimer; - public async Task ScheduleAsync( - int reminderDueSeconds, - int timerDueSeconds, - bool reentrantTimer, - bool recurringTimer) + public async Task ScheduleAsync(int reminderDueSeconds, int timerDueSeconds, bool reentrantTimer, bool recurringTimer) { _events.Clear(); _reentrantTimer = reentrantTimer; _reminder = await this.RegisterOrUpdateReminder( - ReminderName, - TimeSpan.FromSeconds(reminderDueSeconds), - TimeSpan.FromDays(1)); + ReminderName, TimeSpan.FromSeconds(reminderDueSeconds), TimeSpan.FromDays(1)); _timer = TimeProvider.CreateTimer( static state => ((ClockProbe)state!).OnTimer(), this, diff --git a/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs index 4a6ebdc82..eaf3d2300 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/JournalEvidenceContracts.cs @@ -13,15 +13,11 @@ public async Task OutgoingJournalEvidenceCarriesDeliveryMetadata() await using var test = await fixture.CreateBrainAsync(cancellationToken); var greeter = test.Neuron(TestingScenario.WelcomeGreeter); - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var observed = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - observed.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), observed.Synapse.Message); Assert.NotEqual(default, observed.SynapseId); Assert.True(observed.Sequence > 0); Assert.NotEqual(default, observed.CorrelationId); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs index 0b393ed65..c52ef4de2 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/JournalFaultContracts.cs @@ -25,19 +25,13 @@ public async Task FailNextJournalCommitFailsTheNextJournalWrite() await using (var fault = session.FailNextJournalCommit(SessionCommitFailure)) { var failure = await Assert.ThrowsAsync( - () => test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest))); + () => test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest))); Assert.Equal(SessionCommitFailure, failure.Message); } - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var greeted = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - greeted.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), greeted.Synapse.Message); } [Fact(DisplayName = "An unconsumed journal fault fails TestBrain dispose with brain.cleanup diagnostics")] @@ -48,18 +42,11 @@ public async Task UnconsumedJournalFaultFailsBrainDispose() var greeter = test.Neuron(TestingScenario.WelcomeGreeter); _ = greeter.FailNextJournalCommit(NeverFiredFault); - var failure = await Assert.ThrowsAsync( - async () => await test.DisposeAsync()); + var failure = await Assert.ThrowsAsync(async () => await test.DisposeAsync()); - Assert.Contains( - BrainCleanupOperation, - failure.Message, - StringComparison.Ordinal); + Assert.Contains(BrainCleanupOperation, failure.Message, StringComparison.Ordinal); var leak = Assert.IsType(failure.InnerException); - Assert.Contains( - UnconsumedJournalFaultsRemain, - leak.Message, - StringComparison.Ordinal); + Assert.Contains(UnconsumedJournalFaultsRemain, leak.Message, StringComparison.Ordinal); Assert.Contains(NeverFiredFault, leak.Message, StringComparison.Ordinal); Assert.Contains(greeter.Id.ToString(), leak.Message, StringComparison.Ordinal); } @@ -75,12 +62,8 @@ public async Task DisposingAnArmedJournalFaultHandleAllowsCleanBrainDispose() { } - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var greeted = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - greeted.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), greeted.Synapse.Message); } } diff --git a/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs index 6c2e086bd..7baee8bec 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/RestartHostContracts.cs @@ -12,20 +12,14 @@ public async Task RestartHostPreservesCommittedJournalRecords() await using var test = await fixture.CreateBrainAsync(cancellationToken); var greeter = test.Neuron(TestingScenario.WelcomeGreeter); - await test.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await test.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var first = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - first.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), first.Synapse.Message); await greeter.RestartHostAsync(cancellationToken); - var committed = await greeter.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var committed = await greeter.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Single(committed); Assert.Equal(first.SynapseId, committed[0].SynapseId); Assert.Equal(first.Sequence, committed[0].Sequence); diff --git a/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs b/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs index c922defe9..fb892618c 100644 --- a/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs +++ b/src/core/testing/DigitalBrain.Testing.Tests/TestOwnerContracts.cs @@ -17,14 +17,10 @@ public async Task TestOwnerExposesPublicIdAndClient() Assert.Equal(other.Id, other.Client.Owner); var greeter = other.Neuron(TestingScenario.WelcomeGreeter); - await other.Client.SendAsync( - greeter.Id.Name, - new SayHello(TestingScenario.Guest)); + await other.Client.SendAsync(greeter.Id.Name, new SayHello(TestingScenario.Guest)); var greeted = await greeter.Outgoing.NextAsync(cancellationToken); - Assert.Equal( - TestingScenario.GreetedMessage(TestingScenario.Guest), - greeted.Synapse.Message); + Assert.Equal(TestingScenario.GreetedMessage(TestingScenario.Guest), greeted.Synapse.Message); Assert.Equal(other.Id, greeter.Id.Owner); } } diff --git a/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs b/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs index cd281df63..d4101c095 100644 --- a/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs +++ b/src/core/testing/DigitalBrain.Testing/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("DigitalBrain.Mcp.Tests")] [assembly: InternalsVisibleTo("DigitalBrain.Modules.Flutter.Http.Tests")] +[assembly: InternalsVisibleTo("DigitalBrain.OS.Mcp.Tests")] diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs b/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs index 48016ac40..726757da0 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/ControllableTimeProvider.cs @@ -30,11 +30,7 @@ public override DateTimeOffset GetUtcNow() } } - public override ITimer CreateTimer( - TimerCallback callback, - object? state, - TimeSpan dueTime, - TimeSpan period) + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { ArgumentNullException.ThrowIfNull(callback); ValidateTimerValue(dueTime, nameof(dueTime)); @@ -42,11 +38,7 @@ public override ITimer CreateTimer( lock (_gate) { - var registration = new Registration( - this, - callback, - state, - _nextSequence++); + var registration = new Registration(this, callback, state, _nextSequence++); registration.ChangeUnderLock(dueTime, period); _registrations.Add(registration); return registration; @@ -136,10 +128,7 @@ internal void Reset() } } - private static DateTimeOffset Add( - DateTimeOffset value, - TimeSpan duration, - string parameterName) + private static DateTimeOffset Add(DateTimeOffset value, TimeSpan duration, string parameterName) { try { @@ -154,9 +143,7 @@ private static DateTimeOffset Add( } } - private static void ValidateTimerValue( - TimeSpan value, - string parameterName) + private static void ValidateTimerValue(TimeSpan value, string parameterName) { if (value < TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) { @@ -167,11 +154,8 @@ private static void ValidateTimerValue( } } - private sealed class Registration( - ControllableTimeProvider owner, - TimerCallback callback, - object? state, - long sequence) : ITimer + private sealed class Registration(ControllableTimeProvider owner, TimerCallback callback, object? state, long sequence) + : ITimer { private bool _disposed; diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs b/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs index afcb027b7..ec618e9da 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/FixtureCluster.cs @@ -27,13 +27,10 @@ private FixtureCluster(TestFixtureComposition composition) ArgumentNullException.ThrowIfNull(composition); _modules = composition.Modules.ToArray(); _edges = composition.Edges; - _edges.AttachTimeProvider( - _clock, - _clock.Reset); + _edges.AttachTimeProvider(_clock, _clock.Reset); } - internal static async Task StartAsync( - TestFixtureComposition composition) + internal static async Task StartAsync(TestFixtureComposition composition) { var fixture = new FixtureCluster(composition); @@ -67,28 +64,18 @@ internal IGrainFactory Client internal TestEdgeRegistry Edges => _edges; - internal JournalFaultRegistration ArmJournalFault( - NeuronId target, - string message) + internal JournalFaultRegistration ArmJournalFault(NeuronId target, string message) => _journalStorage.ArmFault(target, message); - internal bool DisarmJournalFault( - JournalFaultRegistration registration) + internal bool DisarmJournalFault(JournalFaultRegistration registration) => _journalStorage.DisarmFault(registration); - internal async Task<(TestClock Clock, long EdgeGeneration)> PrepareMethodAsync( - string scope, - BrainTestDiagnostics diagnostics) + + internal async Task<(TestClock Clock, long EdgeGeneration)> PrepareMethodAsync(string scope, BrainTestDiagnostics diagnostics) { var edgeGeneration = _edges.ResetMethodScope(); await _reminderTable.TestOnlyClearTable(); return ( - new TestClock( - _clock, - new TestReminderDriver( - _reminderTable, - Client, - scope), - diagnostics), + new TestClock(_clock, new TestReminderDriver(_reminderTable, Client, scope), diagnostics), edgeGeneration); } @@ -107,9 +94,7 @@ internal static string LabelOf(string siloName) }; } - internal async Task RestartHostAsync( - NeuronId neuron, - CancellationToken cancellationToken) + internal async Task RestartHostAsync(NeuronId neuron, CancellationToken cancellationToken) { var cluster = _cluster ?? throw new InvalidOperationException( @@ -119,13 +104,11 @@ internal async Task RestartHostAsync( .GetDetailedGrainStatistics() .WaitAsync(cancellationToken); var hosting = statistics - .FirstOrDefault(statistic => - statistic.GrainId == neuron.ToGrainId()) + .FirstOrDefault(statistic => statistic.GrainId == neuron.ToGrainId()) ?.SiloAddress ?? throw new InvalidOperationException( $"{neuron} is not activated on any silo, so no host can be restarted."); - var host = cluster.Silos.Single(silo => - silo.SiloAddress.Equals(hosting)); + var host = cluster.Silos.Single(silo => silo.SiloAddress.Equals(hosting)); // Restart mutates assembly-scoped topology. Cancellation may prevent it // from starting, but cannot detach this method from an in-flight restart @@ -174,8 +157,7 @@ private async Task StartCoreAsync() Convert.ToBase64String(new byte[32]); DigitalBrainRuntime.Add(silo, FixtureCluster.LabelOf(options.SiloName), _modules); - silo.Services.AddSingleton(new ReminderSourceAllowlist( - [TestReminderDeliveryService.SourceType])); + silo.Services.AddSingleton(new ReminderSourceAllowlist([TestReminderDeliveryService.SourceType])); silo.Services.AddGrainService(); silo.Services.AddSingleton< ITestReminderDeliveryServiceClient, diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs index 03c47a689..6485b508d 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryCaller.cs @@ -17,8 +17,7 @@ internal sealed class TestReminderDeliveryCaller : public TestReminderDeliveryCaller() { - _service = ServiceProvider - .GetRequiredService(); + _service = ServiceProvider.GetRequiredService(); } public Task Deliver( @@ -27,10 +26,5 @@ public Task Deliver( DateTime firstTickTime, TimeSpan period, DateTime currentTickTime) - => _service.Deliver( - target, - reminderName, - firstTickTime, - period, - currentTickTime); + => _service.Deliver(target, reminderName, firstTickTime, period, currentTickTime); } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs index 7be9a194e..6de21f2d3 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryService.cs @@ -18,18 +18,12 @@ internal sealed class TestReminderDeliveryService : private readonly IGrainFactory _grains; public TestReminderDeliveryService( - GrainId id, - Silo silo, - ILoggerFactory loggerFactory, - IGrainFactory grains) + GrainId id, Silo silo, ILoggerFactory loggerFactory, IGrainFactory grains) : base(id, silo, loggerFactory) { _grains = grains; - if (!string.Equals( - id.Type.ToString(), - SourceType, - StringComparison.Ordinal)) + if (!string.Equals(id.Type.ToString(), SourceType, StringComparison.Ordinal)) { throw new InvalidOperationException( $"The test reminder delivery service source changed from expected '{SourceType}' to '{id.Type}'. Update the exact fixture allowlist deliberately."); @@ -44,7 +38,5 @@ public Task Deliver( DateTime currentTickTime) => _grains .GetGrain(target.ToGrainId()) - .ReceiveReminder( - reminderName, - new TickStatus(firstTickTime, period, currentTickTime)); + .ReceiveReminder(reminderName, new TickStatus(firstTickTime, period, currentTickTime)); } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs index fb59ff75f..a04b6e8a1 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDeliveryServiceClient.cs @@ -20,10 +20,5 @@ public Task Deliver( TimeSpan period, DateTime currentTickTime) => GetGrainService(CurrentGrainReference.GrainId) - .Deliver( - target, - reminderName, - firstTickTime, - period, - currentTickTime); + .Deliver(target, reminderName, firstTickTime, period, currentTickTime); } diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs index 5146d976f..1d15eb316 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderDriver.cs @@ -8,27 +8,19 @@ internal sealed class TestReminderDriver private readonly string _scope; private readonly VolatileReminderTable _table; - internal TestReminderDriver( - VolatileReminderTable table, - IGrainFactory grains, - string scope) + internal TestReminderDriver(VolatileReminderTable table, IGrainFactory grains, string scope) { _table = table; _scope = scope; - var caller = NeuronId.For( - new OwnerId($"{scope}-reminder-driver"), - "caller"); - _caller = grains.GetGrain( - caller.ToGrainId()); + var caller = NeuronId.For(new OwnerId($"{scope}-reminder-driver"), "caller"); + _caller = grains.GetGrain(caller.ToGrainId()); } internal DateTimeOffset? NextDueAtOrBefore(DateTimeOffset target) => _table.NextDueAtOrBefore(target, _scope)?.Due; - internal async Task TryDeliverNextDueAsync( - DateTimeOffset now, - CancellationToken cancellationToken) + internal async Task TryDeliverNextDueAsync(DateTimeOffset now, CancellationToken cancellationToken) { var due = _table.NextDueAtOrBefore(now, _scope); if (due is null) @@ -40,16 +32,10 @@ internal async Task TryDeliverNextDueAsync( var target = NeuronId.FromGrainKey( due.GrainId.Type.ToString() - ?? throw new InvalidOperationException( - "A due reminder has no grain type."), + ?? throw new InvalidOperationException("A due reminder has no grain type."), due.GrainId.Key.ToString()); - await _caller.Deliver( - target, - due.ReminderName, - due.FirstTickTime.UtcDateTime, - due.Period, - due.Due.UtcDateTime); + await _caller.Deliver(target, due.ReminderName, due.FirstTickTime.UtcDateTime, due.Period, due.Due.UtcDateTime); _table.CompleteDelivery(due); return true; diff --git a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs index 3e5b674a8..878df7e93 100644 --- a/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs +++ b/src/core/testing/DigitalBrain.Testing/Cluster/TestReminderRegistry.cs @@ -2,18 +2,13 @@ namespace DigitalBrain.Testing; -internal sealed class TestReminderRegistry( - VolatileReminderTable table, - ControllableTimeProvider clock) : IReminderRegistry +internal sealed class TestReminderRegistry(VolatileReminderTable table, ControllableTimeProvider clock) + : IReminderRegistry { - private static readonly TimeSpan MinimumPeriod = - TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan MinimumPeriod = TimeSpan.FromMilliseconds(50); public async Task RegisterOrUpdateReminder( - GrainId callingGrainId, - string reminderName, - TimeSpan dueTime, - TimeSpan period) + GrainId callingGrainId, string reminderName, TimeSpan dueTime, TimeSpan period) { ValidateName(reminderName); ValidateDueTime(dueTime); @@ -44,9 +39,7 @@ public async Task RegisterOrUpdateReminder( return new ReminderHandle(callingGrainId, reminderName, etag); } - public async Task UnregisterReminder( - GrainId callingGrainId, - IGrainReminder reminder) + public async Task UnregisterReminder(GrainId callingGrainId, IGrainReminder reminder) { ArgumentNullException.ThrowIfNull(reminder); @@ -58,10 +51,7 @@ public async Task UnregisterReminder( nameof(reminder)); } - var removal = table.RemoveRowWithStatus( - callingGrainId, - handle.ReminderName, - handle.ETag); + var removal = table.RemoveRowWithStatus(callingGrainId, handle.ReminderName, handle.ETag); if (removal == ReminderRemovalResult.ETagMismatch) { @@ -72,32 +62,23 @@ public async Task UnregisterReminder( await Task.CompletedTask; } - public async Task GetReminder( - GrainId callingGrainId, - string reminderName) + public async Task GetReminder(GrainId callingGrainId, string reminderName) { ValidateName(reminderName); var entry = await table.ReadRow(callingGrainId, reminderName); return entry is null ? null! - : new ReminderHandle( - entry.GrainId, - entry.ReminderName, - entry.ETag); + : new ReminderHandle(entry.GrainId, entry.ReminderName, entry.ETag); } - public async Task> GetReminders( - GrainId callingGrainId) + public async Task> GetReminders(GrainId callingGrainId) { var data = await table.ReadRows(callingGrainId); return [ .. data.Reminders.Select(entry => - (IGrainReminder)new ReminderHandle( - entry.GrainId, - entry.ReminderName, - entry.ETag)), + (IGrainReminder)new ReminderHandle(entry.GrainId, entry.ReminderName, entry.ETag)), ]; } @@ -122,9 +103,7 @@ private static void ValidateDueTime(TimeSpan dueTime) } } - private static void ValidatePeriod( - string reminderName, - TimeSpan period) + private static void ValidatePeriod(string reminderName, TimeSpan period) { if (period == Timeout.InfiniteTimeSpan || period < TimeSpan.Zero) { @@ -142,8 +121,5 @@ private static void ValidatePeriod( } } - private sealed record ReminderHandle( - GrainId GrainId, - string ReminderName, - string ETag) : IGrainReminder; + private sealed record ReminderHandle(GrainId GrainId, string ReminderName, string ETag) : IGrainReminder; } diff --git a/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs b/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs index d0206445f..a8e54f6af 100644 --- a/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs +++ b/src/core/testing/DigitalBrain.Testing/Diagnostics/BrainTestDiagnostics.cs @@ -7,9 +7,7 @@ internal sealed class BrainTestDiagnostics internal BrainTestDiagnostics(string scopeId) => _scopeId = scopeId; - internal BrainTestFailureException CaptureFailure( - string operation, - Exception failure) + internal BrainTestFailureException CaptureFailure(string operation, Exception failure) { ArgumentNullException.ThrowIfNull(failure); diff --git a/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs b/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs index a2e014da0..7f1735f84 100644 --- a/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs +++ b/src/core/testing/DigitalBrain.Testing/DigitalBrainFixture.cs @@ -16,8 +16,7 @@ public async ValueTask InitializeAsync() _cluster = await FixtureCluster.StartAsync(brain.Seal()); } - public async Task CreateBrainAsync( - CancellationToken cancellationToken = default) + public async Task CreateBrainAsync(CancellationToken cancellationToken = default) { await _methodLease.WaitAsync(cancellationToken); try @@ -25,9 +24,7 @@ public async Task CreateBrainAsync( var scope = $"test-{Guid.NewGuid():N}"; var cluster = Cluster(); var diagnostics = new BrainTestDiagnostics(scope); - var method = await cluster.PrepareMethodAsync( - scope, - diagnostics); + var method = await cluster.PrepareMethodAsync(scope, diagnostics); return new TestBrain( cluster, scope, @@ -63,6 +60,5 @@ public async ValueTask DisposeAsync() private FixtureCluster Cluster() => _cluster - ?? throw new InvalidOperationException( - "The DigitalBrain fixture has not been initialized."); + ?? throw new InvalidOperationException("The DigitalBrain fixture has not been initialized."); } diff --git a/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs b/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs index e3d622045..b4f008955 100644 --- a/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs +++ b/src/core/testing/DigitalBrain.Testing/DigitalBrainTestBuilder.cs @@ -26,25 +26,15 @@ public void AddModule() [EditorBrowsable(EditorBrowsableState.Never)] public void ConfigureChatClient( - IReadOnlyCollection neuronAliases, - TService adapter, - TScript script, - Action reset) + IReadOnlyCollection neuronAliases, TService adapter, TScript script, Action reset) where TService : class where TScript : class { ThrowIfSealed(); - _edges.ConfigureChatClient( - neuronAliases, - adapter, - script, - reset); + _edges.ConfigureChatClient(neuronAliases, adapter, script, reset); } - internal void ConfigureMcpSessionFactory( - IMcpClientSessionFactory factory, - TScript script, - Action reset) + internal void ConfigureMcpSessionFactory(IMcpClientSessionFactory factory, TScript script, Action reset) where TScript : class { ThrowIfSealed(); @@ -54,9 +44,7 @@ internal void ConfigureMcpSessionFactory( internal TestFixtureComposition Seal() { _sealed = true; - return new( - _modules.Values.ToArray(), - _edges); + return new(_modules.Values.ToArray(), _edges); } private void ThrowIfSealed() @@ -69,6 +57,4 @@ private void ThrowIfSealed() } } -internal sealed record TestFixtureComposition( - IReadOnlyCollection Modules, - TestEdgeRegistry Edges); +internal sealed record TestFixtureComposition(IReadOnlyCollection Modules, TestEdgeRegistry Edges); diff --git a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs index b1701b1ab..d0d0ff18a 100644 --- a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs +++ b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatClient.cs @@ -8,6 +8,7 @@ public sealed class ScriptedChatClient : IChatClient private readonly Lock _gate = new(); private readonly Queue _replies = []; private int _callCount; + private ChatMessage[] _lastMessages = []; public int CallCount { @@ -20,6 +21,17 @@ public int CallCount } } + public IReadOnlyList LastMessages + { + get + { + lock (_gate) + { + return [.. _lastMessages]; + } + } + } + public void Reply(string text) { ArgumentException.ThrowIfNullOrWhiteSpace(text); @@ -41,6 +53,7 @@ public void Reset() lock (_gate) { _callCount = 0; + _lastMessages = []; _replies.Clear(); } } @@ -90,9 +103,11 @@ private ChatResponse NextResponse( cancellationToken.ThrowIfCancellationRequested(); ChatMessage reply; + var request = messages.ToArray(); lock (_gate) { _callCount++; + _lastMessages = request; reply = _replies.Count > 0 ? _replies.Dequeue() : throw new InvalidOperationException( diff --git a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs index ae3192f15..14ee6ac80 100644 --- a/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs +++ b/src/core/testing/DigitalBrain.Testing/Edges/ScriptedChatExtensions.cs @@ -20,10 +20,7 @@ public static ScriptedChatClient ConfigureScriptedChat( var script = new ScriptedChatClient(); brain.ConfigureChatClient( - models, - script, - script, - static scripted => scripted.Reset()); + models, script, script, static scripted => scripted.Reset()); return script; } diff --git a/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs b/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs index 5f19610b2..b0707c132 100644 --- a/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs +++ b/src/core/testing/DigitalBrain.Testing/Edges/TestEdgeRegistry.cs @@ -15,10 +15,7 @@ internal sealed class TestEdgeRegistry private long _methodGeneration; internal void ConfigureChatClient( - IReadOnlyCollection neuronAliases, - TService adapter, - TScript script, - Action reset) + IReadOnlyCollection neuronAliases, TService adapter, TScript script, Action reset) where TService : class where TScript : class { @@ -55,19 +52,11 @@ internal void ConfigureChatClient( "The chat-client test edge already has an assembly-configured adapter."); } - _chatClient = new( - serviceType, - aliases, - adapter, - script, - () => reset(script)); + _chatClient = new(serviceType, aliases, adapter, script, () => reset(script)); } } - internal void ConfigureMcpSessionFactory( - IMcpClientSessionFactory factory, - TScript script, - Action reset) + internal void ConfigureMcpSessionFactory(IMcpClientSessionFactory factory, TScript script, Action reset) where TScript : class { ArgumentNullException.ThrowIfNull(factory); @@ -86,9 +75,7 @@ internal void ConfigureMcpSessionFactory( } } - internal void AttachTimeProvider( - TimeProvider provider, - Action reset) + internal void AttachTimeProvider(TimeProvider provider, Action reset) { ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(reset); @@ -127,10 +114,7 @@ internal void ConfigureServices(IServiceCollection services) { foreach (var serviceKey in chatClient.ServiceKeys) { - services.AddKeyedSingleton( - chatClient.ServiceType, - serviceKey, - chatClient.Adapter); + services.AddKeyedSingleton(chatClient.ServiceType, serviceKey, chatClient.Adapter); } } @@ -140,9 +124,7 @@ internal void ConfigureServices(IServiceCollection services) services.AddSingleton(mcp.Factory); } - services.AddKeyedSingleton( - NeuronTime.ServiceKey, - timeProvider); + services.AddKeyedSingleton(NeuronTime.ServiceKey, timeProvider); } internal long ResetMethodScope() @@ -216,14 +198,7 @@ private void EnsureCurrentGeneration(long generation) } private sealed record EdgeRegistration( - Type ServiceType, - IReadOnlyList ServiceKeys, - object Adapter, - object Script, - Action Reset); - - private sealed record McpEdgeRegistration( - IMcpClientSessionFactory Factory, - object Script, - Action Reset); + Type ServiceType, IReadOnlyList ServiceKeys, object Adapter, object Script, Action Reset); + + private sealed record McpEdgeRegistration(IMcpClientSessionFactory Factory, object Script, Action Reset); } diff --git a/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs b/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs index 0b4a3bc19..62c42a747 100644 --- a/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs +++ b/src/core/testing/DigitalBrain.Testing/Faults/JournalFaultHandle.cs @@ -9,9 +9,7 @@ public sealed class JournalFaultHandle : IAsyncDisposable private readonly BrainTestDiagnostics _diagnostics; internal JournalFaultHandle( - JournalFaultRegistration registration, - Func disarm, - BrainTestDiagnostics diagnostics) + JournalFaultRegistration registration, Func disarm, BrainTestDiagnostics diagnostics) { Registration = registration; _disarm = disarm; @@ -39,15 +37,10 @@ public ValueTask DisposeAsync() } catch (Exception failure) { - return ValueTask.FromException( - _diagnostics.CaptureFailure( - "fault.dispose", - failure)); + return ValueTask.FromException(_diagnostics.CaptureFailure("fault.dispose", failure)); } } internal bool Disarm() - => Interlocked.Exchange(ref _disarm, null) - ?.Invoke(this) - ?? false; + => Interlocked.Exchange(ref _disarm, null)?.Invoke(this) ?? false; } diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs b/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs index 9aa46b09f..229445903 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/AppHostExclusiveLease.cs @@ -9,8 +9,7 @@ private AppHostExclusiveLease() { } - internal static async Task AcquireAsync( - CancellationToken cancellationToken) + internal static async Task AcquireAsync(CancellationToken cancellationToken) { await Gate.WaitAsync(cancellationToken); return new AppHostExclusiveLease(); diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs b/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs index 6e9752735..8c065a5e7 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/DigitalBrainAppHostFixture.cs @@ -5,8 +5,7 @@ namespace DigitalBrain.Testing; -public abstract class DigitalBrainAppHostFixture : IAsyncLifetime - where TAppHost : class +public abstract class DigitalBrainAppHostFixture : IAsyncLifetime { private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(5); private readonly object _sync = new(); @@ -28,23 +27,49 @@ public Task StartAsync( return StartCoreAsync(cancellationToken); } - public ValueTask DisposeAsync() + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Fixture disposal must tear down any active graph even when stop fails; original cleanup failure is rethrown after best-effort dispose.")] + public async ValueTask DisposeAsync() { + RunningAppHost? active; + var pending = 0; lock (_sync) { _disposed = true; - if (_pendingStarts != 0 || _active is not null) + active = _active; + _active = null; + pending = _pendingStarts; + _pendingStarts = 0; + } + + GC.SuppressFinalize(this); + + Exception? cleanupFailure = null; + if (active is not null) + { + try { - return ValueTask.FromException( - new InvalidOperationException( - "The AppHost fixture was disposed with a pending start or active graph handle.")); + await active.DisposeAsync(); + } + catch (Exception failure) + { + cleanupFailure = failure; } } - GC.SuppressFinalize(this); - return ValueTask.CompletedTask; + if (pending != 0 || cleanupFailure is not null) + { + throw new InvalidOperationException( + "The AppHost fixture was disposed with a pending start or active graph handle.", + cleanupFailure); + } } + protected abstract Task BuildApplicationAsync( + CancellationToken cancellationToken); + [SuppressMessage( "Design", "CA1031:Do not catch general exception types", @@ -62,18 +87,11 @@ private async Task StartCoreAsync( try { lease = await AppHostExclusiveLease.AcquireAsync(cancellationToken); - var builder = await DistributedApplicationTestingBuilder - .CreateAsync( - args: [], - configureBuilder: static (options, _) => - options.EnableResourceLogging = true, - cancellationToken); - using var startup = CancellationTokenSource .CreateLinkedTokenSource(cancellationToken); startup.CancelAfter(StartupTimeout); - application = await builder.BuildAsync(startup.Token); + application = await BuildApplicationAsync(startup.Token); await application.StartAsync(startup.Token); var running = new RunningAppHost( @@ -95,7 +113,6 @@ private async Task StartCoreAsync( } catch { - // Preserve the startup failure. } } @@ -107,7 +124,6 @@ private async Task StartCoreAsync( } catch { - // Preserve the startup failure. } } @@ -160,3 +176,29 @@ private void Release(RunningAppHost running) } } } + +public abstract class DigitalBrainAppHostFixture : DigitalBrainAppHostFixture + where TAppHost : class +{ + private static readonly bool ResourceLoggingEnabled = + string.Equals( + Environment.GetEnvironmentVariable("DIGITALBRAIN_APPHOST_RESOURCE_LOGS"), + "1", + StringComparison.Ordinal) + || string.Equals( + Environment.GetEnvironmentVariable("DIGITALBRAIN_APPHOST_RESOURCE_LOGS"), + "true", + StringComparison.OrdinalIgnoreCase); + + protected override async Task BuildApplicationAsync( + CancellationToken cancellationToken) + { + var builder = await DistributedApplicationTestingBuilder + .CreateAsync( + args: [], + configureBuilder: static (options, _) => + options.EnableResourceLogging = ResourceLoggingEnabled, + cancellationToken); + return await builder.BuildAsync(cancellationToken); + } +} diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs b/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs index 175a1dc45..887fce272 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/HostedResource.cs @@ -11,8 +11,7 @@ internal HostedResource(RunningAppHost host, string name) _name = name; } - public Task WaitUntilHealthyAsync( - CancellationToken cancellationToken = default) + public Task WaitUntilHealthyAsync(CancellationToken cancellationToken = default) => _host.WaitUntilHealthyAsync(_name, cancellationToken); public HttpClient CreateHttpClient() diff --git a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs index a08ef4856..be66273a9 100644 --- a/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs +++ b/src/core/testing/DigitalBrain.Testing/Hosting/RunningAppHost.cs @@ -8,7 +8,7 @@ namespace DigitalBrain.Testing; public sealed class RunningAppHost : IAsyncDisposable { - private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(5); private readonly DistributedApplication _application; @@ -78,10 +78,6 @@ public async ValueTask DisposeAsync() using var cleanup = new CancellationTokenSource(_cleanupTimeout); var failures = new List(); - await AttemptAsync( - () => StopRuntimeResourcesAsync(cleanup.Token), - failures, - cleanup.Token); await AttemptAsync( () => _application.StopAsync(cleanup.Token), failures, @@ -182,58 +178,6 @@ private static void ReportFailures(List failures) primary); } - private async Task StopRuntimeResourcesAsync( - CancellationToken cancellationToken) - { - var stoppable = _resourceNames - .Where(id => !IsTerminal(id) && AdvertisesStop(id)) - .Order(StringComparer.Ordinal) - .ToArray(); - - await Task.WhenAll( - stoppable.Select(id => StopOneAsync(id, cancellationToken))); - } - - private bool AdvertisesStop(string resourceId) - => _application.ResourceNotifications.TryGetCurrentState( - resourceId, - out var resourceEvent) - && resourceEvent is not null - && resourceEvent.Snapshot.Commands.Any(command => - string.Equals( - command.Name, - KnownResourceCommands.StopCommand, - StringComparison.Ordinal)); - - private bool IsTerminal(string resourceId) - => _application.ResourceNotifications.TryGetCurrentState( - resourceId, - out var resourceEvent) - && resourceEvent?.Snapshot.State?.Text is { } state - && KnownResourceStates.TerminalStates.Contains( - state, - StringComparer.Ordinal); - - private async Task StopOneAsync( - string resourceId, - CancellationToken cancellationToken) - { - var result = await _application.ResourceCommands.ExecuteCommandAsync( - resourceId, - KnownResourceCommands.StopCommand, - cancellationToken); - -#pragma warning disable CS0618 - var errorMessage = result.ErrorMessage; -#pragma warning restore CS0618 - - if (!result.Success && !IsTerminal(resourceId)) - { - throw new InvalidOperationException( - $"Stop failed for '{resourceId}': {errorMessage ?? result.Message}"); - } - } - private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf( Volatile.Read(ref _disposed) != 0, diff --git a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs index d5dba106d..a8351b572 100644 --- a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs +++ b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.Watch.cs @@ -7,8 +7,7 @@ namespace DigitalBrain.Testing; public sealed partial class TestJournal { - private async Task> NextWrappedAsync( - CancellationToken cancellationToken) + private async Task> NextWrappedAsync(CancellationToken cancellationToken) where TSynapse : Synapse { try @@ -21,8 +20,7 @@ private async Task> NextWrappedAsync( } } - private async Task> NextCoreAsync( - CancellationToken cancellationToken) + private async Task> NextCoreAsync(CancellationToken cancellationToken) where TSynapse : Synapse { await _nextGate.WaitAsync(cancellationToken); @@ -62,9 +60,7 @@ private async Task> NextCoreAsync( } } - private SynapseDelivery? Accept( - TestJournalObserver observer, - JournalRead batch) + private SynapseDelivery? Accept(TestJournalObserver observer, JournalRead batch) where TSynapse : Synapse { if (batch.ResetSnapshot is not null) @@ -104,8 +100,7 @@ private async Task> NextCoreAsync( "Reliability", "CA2000:Dispose objects before losing scope", Justification = "Observer ownership transfers on success.")] - private async Task EnsureWatchingAsync( - CancellationToken cancellationToken) + private async Task EnsureWatchingAsync(CancellationToken cancellationToken) { await _setupGate.WaitAsync(cancellationToken); try @@ -121,11 +116,7 @@ private async Task EnsureWatchingAsync( try { reference = _cluster.Client.CreateObjectReference(observer); - await _session.WatchNeuron( - _subject, - _direction, - afterSequence: 0, - reference); + await _session.WatchNeuron(_subject, _direction, afterSequence: 0, reference); _observer = observer; _reference = reference; _watching = true; @@ -178,9 +169,7 @@ private void RetireNextTask(Task task) } } - private ObservedSynapse Observe( - TSynapse synapse, - SynapseDelivery delivery) + private ObservedSynapse Observe(TSynapse synapse, SynapseDelivery delivery) where TSynapse : Synapse => new( synapse, @@ -207,7 +196,5 @@ private ObservedSynapse Observe( } private void ThrowIfDisposed() - => ObjectDisposedException.ThrowIf( - Volatile.Read(ref _disposed) != 0, - this); + => ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); } diff --git a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs index 05865c9e7..f5a9e939a 100644 --- a/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs +++ b/src/core/testing/DigitalBrain.Testing/Journals/TestJournal.cs @@ -25,11 +25,7 @@ public sealed partial class TestJournal private IJournalObserver? _reference; private bool _watching; - internal TestJournal( - FixtureCluster cluster, - NeuronId subject, - JournalKind direction, - BrainTestDiagnostics diagnostics) + internal TestJournal(FixtureCluster cluster, NeuronId subject, JournalKind direction, BrainTestDiagnostics diagnostics) { _cluster = cluster; _subject = subject; @@ -39,8 +35,7 @@ internal TestJournal( ISessionNeuron.ForOwner(subject.Owner).ToGrainId()); } - public Task> NextAsync( - CancellationToken cancellationToken = default) + public Task> NextAsync(CancellationToken cancellationToken = default) where TSynapse : Synapse { lock (_nextTasksGate) @@ -56,8 +51,7 @@ public Task> NextAsync( var task = NextWrappedAsync(cancellationToken); _outstandingNextTasks.Add(task); _ = task.ContinueWith( - static (completed, state) => - ((TestJournal)state!).RetireNextTask(completed), + static (completed, state) => ((TestJournal)state!).RetireNextTask(completed), this, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, @@ -67,8 +61,7 @@ public Task> NextAsync( } public async Task>> ReadAsync( - long afterSequence = 0, - CancellationToken cancellationToken = default) + long afterSequence = 0, CancellationToken cancellationToken = default) where TSynapse : Synapse { try @@ -89,9 +82,7 @@ public async Task>> ReadAsync( [ .. read.Delta .Where(delivery => delivery.Synapse is TSynapse) - .Select(delivery => Observe( - (TSynapse)delivery.Synapse, - delivery)), + .Select(delivery => Observe((TSynapse)delivery.Synapse, delivery)), ]; } catch (Exception failure) when (failure is not BrainTestFailureException) diff --git a/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs b/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs index d52b87787..1f72de732 100644 --- a/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs +++ b/src/core/testing/DigitalBrain.Testing/RecordingJournalStorageProvider.cs @@ -13,9 +13,7 @@ internal sealed class RecordingJournalStorageProvider(IJournalStorageProvider in public IJournalStorage CreateStorage(JournalId journalId) => new RecordingJournalStorage(this, journalId, inner.CreateStorage(journalId)); - internal JournalFaultRegistration ArmFault( - NeuronId target, - string message) + internal JournalFaultRegistration ArmFault(NeuronId target, string message) { ArgumentException.ThrowIfNullOrWhiteSpace(message); @@ -81,17 +79,13 @@ private sealed class RecordingJournalStorage( { public bool IsCompactionRequested => inner.IsCompactionRequested; - public async ValueTask AppendAsync( - ReadOnlySequence value, - CancellationToken cancellationToken) + public async ValueTask AppendAsync(ReadOnlySequence value, CancellationToken cancellationToken) { recorder.BeforeWrite(journalId); await inner.AppendAsync(value, cancellationToken); } - public async ValueTask ReplaceAsync( - ReadOnlySequence value, - CancellationToken cancellationToken) + public async ValueTask ReplaceAsync(ReadOnlySequence value, CancellationToken cancellationToken) { recorder.BeforeWrite(journalId); await inner.ReplaceAsync(value, cancellationToken); @@ -108,9 +102,7 @@ public ValueTask DeleteAsync(CancellationToken cancellationToken) public ValueTask GetMetadataAsync(CancellationToken cancellationToken) => inner.GetMetadataAsync(cancellationToken); - public ValueTask ReadAsync( - IJournalStorageConsumer consumer, - CancellationToken cancellationToken) + public ValueTask ReadAsync(IJournalStorageConsumer consumer, CancellationToken cancellationToken) => inner.ReadAsync(consumer, cancellationToken); public ValueTask UpdateMetadataAsync( @@ -122,8 +114,4 @@ public ValueTask ReadAsync( } } -internal sealed record JournalFaultRegistration( - NeuronId Target, - string Message, - Task Consumed, - object Token); +internal sealed record JournalFaultRegistration(NeuronId Target, string Message, Task Consumed, object Token); diff --git a/src/core/testing/DigitalBrain.Testing/TestBrain.cs b/src/core/testing/DigitalBrain.Testing/TestBrain.cs index e0d93e85b..806893c05 100644 --- a/src/core/testing/DigitalBrain.Testing/TestBrain.cs +++ b/src/core/testing/DigitalBrain.Testing/TestBrain.cs @@ -168,22 +168,15 @@ public async ValueTask DisposeAsync() } } - internal JournalFaultHandle ArmJournalFault( - NeuronId target, - string message) + internal JournalFaultHandle ArmJournalFault(NeuronId target, string message) { lock (_faultGate) { try { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); var registration = Cluster.ArmJournalFault(target, message); - var handle = new JournalFaultHandle( - registration, - RetireJournalFault, - _diagnostics); + var handle = new JournalFaultHandle(registration, RetireJournalFault, _diagnostics); _faults.Add(handle); return handle; } @@ -198,18 +191,12 @@ internal TestJournal Journal(NeuronId subject, JournalKind direction) { lock (_journalGate) { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); var key = (subject, direction); if (!_journals.TryGetValue(key, out var journal)) { - journal = new TestJournal( - Cluster, - subject, - direction, - _diagnostics); + journal = new TestJournal(Cluster, subject, direction, _diagnostics); _journals.Add(key, journal); } @@ -217,15 +204,11 @@ internal TestJournal Journal(NeuronId subject, JournalKind direction) } } - internal async Task RestartHostAsync( - NeuronId target, - CancellationToken cancellationToken) + internal async Task RestartHostAsync(NeuronId target, CancellationToken cancellationToken) { try { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); await Cluster.RestartHostAsync(target, cancellationToken); } catch (Exception failure) when (failure is not BrainTestFailureException) @@ -238,13 +221,9 @@ private TestOwner CreateOwner(string label) => new(this, new($"{_scope}-{label}")); private void ThrowIfDisposed() - => ObjectDisposedException.ThrowIf( - Volatile.Read(ref _release) is null, - this); + => ObjectDisposedException.ThrowIf(Volatile.Read(ref _release) is null, this); - internal BrainTestFailureException CaptureFailure( - string operation, - Exception failure) + internal BrainTestFailureException CaptureFailure(string operation, Exception failure) => _diagnostics.CaptureFailure(operation, failure); private bool RetireJournalFault(JournalFaultHandle fault) @@ -278,8 +257,7 @@ private bool RetireJournalFault(JournalFaultHandle fault) continue; } - (leaks ??= []).Add( - $"neuron='{fault.Target}', message='{fault.Message}'"); + (leaks ??= []).Add($"neuron='{fault.Target}', message='{fault.Message}'"); } return leaks is null @@ -317,8 +295,7 @@ private async Task DisposeJournalsAsync() if (failures is not null) { throw new AggregateException( - "One or more DigitalBrain test journals failed to clean up.", - failures); + "One or more DigitalBrain test journals failed to clean up.", failures); } } } diff --git a/src/core/testing/DigitalBrain.Testing/TestClock.cs b/src/core/testing/DigitalBrain.Testing/TestClock.cs index 275fba4bb..e50da136d 100644 --- a/src/core/testing/DigitalBrain.Testing/TestClock.cs +++ b/src/core/testing/DigitalBrain.Testing/TestClock.cs @@ -16,10 +16,7 @@ public sealed class TestClock private readonly TestReminderDriver _reminders; private bool _disposed; - internal TestClock( - ControllableTimeProvider provider, - TestReminderDriver reminders, - BrainTestDiagnostics diagnostics) + internal TestClock(ControllableTimeProvider provider, TestReminderDriver reminders, BrainTestDiagnostics diagnostics) { _provider = provider; _reminders = reminders; @@ -30,36 +27,26 @@ public DateTimeOffset UtcNow { get { - ObjectDisposedException.ThrowIf( - Volatile.Read(ref _disposed), - this); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed), this); return _provider.GetUtcNow(); } } - public async Task AdvanceAsync( - TimeSpan duration, - CancellationToken cancellationToken = default) + public async Task AdvanceAsync(TimeSpan duration, CancellationToken cancellationToken = default) { try { - ArgumentOutOfRangeException.ThrowIfLessThan( - duration, - TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); await AdvanceCoreAsync(duration, cancellationToken); } catch (Exception failure) when (failure is not BrainTestFailureException) { - throw _diagnostics.CaptureFailure( - "clock.advance", - failure); + throw _diagnostics.CaptureFailure("clock.advance", failure); } } - private async Task AdvanceCoreAsync( - TimeSpan duration, - CancellationToken cancellationToken) + private async Task AdvanceCoreAsync(TimeSpan duration, CancellationToken cancellationToken) { await _advanceGate.WaitAsync(cancellationToken); try @@ -88,9 +75,7 @@ private async Task AdvanceCoreAsync( "A deterministic timer disappeared before it could be fired."); } } - else if (!await _reminders.TryDeliverNextDueAsync( - target, - cancellationToken)) + else if (!await _reminders.TryDeliverNextDueAsync(target, cancellationToken)) { throw new InvalidOperationException( "A deterministic reminder disappeared before it could be delivered."); @@ -124,9 +109,7 @@ private static InvalidOperationException DrainLimitFailure(DateTimeOffset target => new( $"Deterministic time drain exceeded {MaximumDrainOperations} operations while advancing to {target:O}."); - private bool TrySelectNextDue( - DateTimeOffset target, - out bool nextIsTimer) + private bool TrySelectNextDue(DateTimeOffset target, out bool nextIsTimer) { var timer = _provider.NextDueAtOrBefore(target); var reminder = _reminders.NextDueAtOrBefore(target); @@ -143,9 +126,7 @@ private bool TrySelectNextDue( return true; } - private static DateTimeOffset Add( - DateTimeOffset value, - TimeSpan duration) + private static DateTimeOffset Add(DateTimeOffset value, TimeSpan duration) { try { diff --git a/src/core/testing/DigitalBrain.Testing/TestNeuron.cs b/src/core/testing/DigitalBrain.Testing/TestNeuron.cs index 81deb6ffd..098f4d300 100644 --- a/src/core/testing/DigitalBrain.Testing/TestNeuron.cs +++ b/src/core/testing/DigitalBrain.Testing/TestNeuron.cs @@ -7,12 +7,7 @@ public sealed class TestNeuron { private readonly TestBrain _brain; - internal TestNeuron( - TestBrain brain, - NeuronId id, - TNeuron reference, - TestJournal incoming, - TestJournal outgoing) + internal TestNeuron(TestBrain brain, NeuronId id, TNeuron reference, TestJournal incoming, TestJournal outgoing) { _brain = brain; Id = id; @@ -32,7 +27,6 @@ internal TestNeuron( public JournalFaultHandle FailNextJournalCommit(string message) => _brain.ArmJournalFault(Id, message); - public Task RestartHostAsync( - CancellationToken cancellationToken = default) + public Task RestartHostAsync(CancellationToken cancellationToken = default) => _brain.RestartHostAsync(Id, cancellationToken); } diff --git a/src/core/testing/DigitalBrain.Testing/TestOwner.cs b/src/core/testing/DigitalBrain.Testing/TestOwner.cs index 47e771221..51c722b6a 100644 --- a/src/core/testing/DigitalBrain.Testing/TestOwner.cs +++ b/src/core/testing/DigitalBrain.Testing/TestOwner.cs @@ -40,9 +40,7 @@ public TestNeuron Neuron(string name = "default") catch (Exception failure) when (failure is not BrainTestFailureException) { - throw _brain.CaptureFailure( - "neuron.open", - failure); + throw _brain.CaptureFailure("neuron.open", failure); } } } diff --git a/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs b/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs index 1e1adf238..7ed701bb7 100644 --- a/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs +++ b/src/core/testing/DigitalBrain.Testing/VolatileReminderTable.cs @@ -44,10 +44,7 @@ public Task ReadRows(uint begin, uint end) { return Task.FromResult(new ReminderTableData( _entries.Values - .Where(entry => InRange( - entry.Entry.GrainId.GetUniformHashCode(), - begin, - end)) + .Where(entry => InRange(entry.Entry.GrainId.GetUniformHashCode(), begin, end)) .Select(entry => Copy(entry.Entry)))); } } @@ -61,23 +58,15 @@ public Task UpsertRow(ReminderEntry entry) var etag = Guid.NewGuid().ToString("N"); var stored = Copy(entry); stored.ETag = etag; - _entries[(stored.GrainId, stored.ReminderName)] = new( - stored, - Utc(stored.StartAt), - _nextSequence++); + _entries[(stored.GrainId, stored.ReminderName)] = new(stored, Utc(stored.StartAt), _nextSequence++); return Task.FromResult(etag); } } public Task RemoveRow(GrainId grainId, string reminderName, string eTag) - => Task.FromResult( - RemoveRowWithStatus(grainId, reminderName, eTag) - == ReminderRemovalResult.Removed); - - internal ReminderRemovalResult RemoveRowWithStatus( - GrainId grainId, - string reminderName, - string eTag) + => Task.FromResult(RemoveRowWithStatus(grainId, reminderName, eTag) == ReminderRemovalResult.Removed); + + internal ReminderRemovalResult RemoveRowWithStatus(GrainId grainId, string reminderName, string eTag) { lock (_gate) { @@ -88,10 +77,7 @@ internal ReminderRemovalResult RemoveRowWithStatus( return ReminderRemovalResult.Missing; } - if (!string.Equals( - entry.Entry.ETag, - eTag, - StringComparison.Ordinal)) + if (!string.Equals(entry.Entry.ETag, eTag, StringComparison.Ordinal)) { return ReminderRemovalResult.ETagMismatch; } @@ -111,9 +97,7 @@ public Task TestOnlyClearTable() } } - internal DueReminder? NextDueAtOrBefore( - DateTimeOffset target, - string scope) + internal DueReminder? NextDueAtOrBefore(DateTimeOffset target, string scope) { lock (_gate) { @@ -140,10 +124,7 @@ internal void CompleteDelivery(DueReminder delivered) { var key = (delivered.GrainId, delivered.ReminderName); if (!_entries.TryGetValue(key, out var current) - || !string.Equals( - current.Entry.ETag, - delivered.ETag, - StringComparison.Ordinal) + || !string.Equals(current.Entry.ETag, delivered.ETag, StringComparison.Ordinal) || current.NextDue != delivered.Due) { return; @@ -160,28 +141,18 @@ private static bool InRange(uint hash, uint begin, uint end) private static bool BelongsToScope(GrainId grainId, string scope) { - if (string.Equals( - grainId.Type.ToString(), - OutboxWakeup.GrainTypeName, - StringComparison.Ordinal)) + if (string.Equals(grainId.Type.ToString(), OutboxWakeup.GrainTypeName, StringComparison.Ordinal)) { - return OutboxWakeup.TryParseTarget( - grainId.Key.ToString(), - out var target) - && target.Owner.Value.StartsWith( - $"{scope}-", - StringComparison.Ordinal); + return OutboxWakeup.TryParseTarget(grainId.Key.ToString(), out var target) + && target.Owner.Value.StartsWith($"{scope}-", StringComparison.Ordinal); } var id = NeuronId.FromGrainKey( grainId.Type.ToString() - ?? throw new InvalidOperationException( - "A reminder grain has no grain type."), + ?? throw new InvalidOperationException("A reminder grain has no grain type."), grainId.Key.ToString()); - return id.Owner.Value.StartsWith( - $"{scope}-", - StringComparison.Ordinal); + return id.Owner.Value.StartsWith($"{scope}-", StringComparison.Ordinal); } private static DateTimeOffset Utc(DateTime value) @@ -196,10 +167,7 @@ private static DateTimeOffset Utc(DateTime value) ETag = entry.ETag, }; - private sealed class StoredReminder( - ReminderEntry entry, - DateTimeOffset nextDue, - long sequence) + private sealed class StoredReminder(ReminderEntry entry, DateTimeOffset nextDue, long sequence) { internal ReminderEntry Entry { get; } = entry; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs index e55d20695..564a6f031 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Aspire.Hosting/AIHostingExtensions.cs @@ -9,35 +9,55 @@ namespace DigitalBrain.AI.Aspire.Hosting; public static class AIHostingExtensions { - public static DigitalBrainModuleBuilder WithLlm( - this DigitalBrainModuleBuilder module) + private const string EnableSensitiveDataEnvironmentKey = + "DigitalBrain__AI__Telemetry__EnableSensitiveData"; + + public const string Llama32Feature = "ai.llm.llama32"; + public const string Gemma4Feature = "ai.llm.gemma4"; + public const string Qwen35Feature = "ai.llm.qwen35"; + public const string Granite41Feature = "ai.llm.granite41"; + public const string Gpt56Feature = "ai.llm.gpt56"; + + extension(DigitalBrainModuleBuilder module) + { + public bool EnableSensitiveData + { + get => State(module).EnableSensitiveData; + set => State(module).EnableSensitiveData = value; + } + } + + public static DigitalBrainModuleBuilder WithLlm(this DigitalBrainModuleBuilder module) where TModel : class, ILLM { - ArgumentNullException.ThrowIfNull(module); + var state = State(module); + module.ConfigureFeature(state.Add()); + return module; + } - var state = module.Brain.GetOrAddState( - static brain => new AIHostingState(brain), - out var added); + private static AIHostingState State(DigitalBrainModuleBuilder module) + { + ArgumentNullException.ThrowIfNull(module); + var state = module.Brain.GetOrAddState(static brain => new AIHostingState(brain), out var added); if (added) { module.RequireStateProtection(); module.AddProjection(state); } - state.Add(); - return module; + return state; } private sealed class AIHostingState(DigitalBrainBuilder brain) : DigitalBrainModuleProjection { private const string OllamaImageTag = "latest"; - private static readonly Dictionary OllamaModelCatalog = new() + private static readonly Dictionary OllamaModelCatalog = new() { - [typeof(Llama32)] = ("llama32", "llama3.2"), - [typeof(Gemma4)] = ("gemma4", "gemma4:12b"), - [typeof(Qwen35)] = ("qwen35", "qwen3.5:9b"), - [typeof(Granite41)] = ("granite41", "granite4.1:8b"), + [typeof(Llama32)] = ("llama32", "llama3.2", Llama32Feature), + [typeof(Gemma4)] = ("gemma4", "gemma4:12b", Gemma4Feature), + [typeof(Qwen35)] = ("qwen35", "qwen3.5:9b", Qwen35Feature), + [typeof(Granite41)] = ("granite41", "granite4.1:8b", Granite41Feature), }; private readonly HashSet _models = []; @@ -47,7 +67,9 @@ private sealed class AIHostingState(DigitalBrainBuilder brain) : DigitalBrainMod private IResourceBuilder? _gpt56; private IResourceBuilder? _openAIKey; - internal void Add() + internal bool EnableSensitiveData { get; set; } + + internal string Add() where TModel : class, ILLM { var model = typeof(TModel); @@ -61,13 +83,13 @@ internal void Add() if (OllamaModelCatalog.TryGetValue(model, out var ollama)) { AddOllamaModel(model, ollama.ResourceSuffix, ollama.Tag); - return; + return ollama.Feature; } if (model == typeof(Gpt56)) { AddGpt56(); - return; + return Gpt56Feature; } throw new NotSupportedException( @@ -78,33 +100,24 @@ public override void Apply(IResourceBuilder builder) { ArgumentNullException.ThrowIfNull(builder); + builder.WithEnvironment( + EnableSensitiveDataEnvironmentKey, + EnableSensitiveData.ToString()); + foreach (var (model, resource) in _ollamaModels) { builder - .WithAnnotation(new WaitAnnotation( - resource.Resource, - WaitType.WaitUntilHealthy, - exitCode: 0)) - .WithEnvironment( - "DigitalBrain__AI__Ollama__Endpoint", - resource.Resource.Parent.UriExpression) - .WithEnvironment( - $"DigitalBrain__AI__Ollama__{model.Name}__Model", - resource.Resource.ModelName); + .WithAnnotation(new WaitAnnotation(resource.Resource, WaitType.WaitUntilHealthy, exitCode: 0)) + .WithEnvironment("DigitalBrain__AI__Ollama__Endpoint", resource.Resource.Parent.UriExpression) + .WithEnvironment($"DigitalBrain__AI__Ollama__{model.Name}__Model", resource.Resource.ModelName); } if (_gpt56 is not null) { builder - .WithEnvironment( - "DigitalBrain__AI__OpenAI__ApiKey", - _openAIKey!) - .WithEnvironment( - "DigitalBrain__AI__OpenAI__Endpoint", - _gpt56.Resource.Parent.UriExpression) - .WithEnvironment( - "DigitalBrain__AI__OpenAI__Gpt56__Model", - _gpt56.Resource.Model); + .WithEnvironment("DigitalBrain__AI__OpenAI__ApiKey", _openAIKey!) + .WithEnvironment("DigitalBrain__AI__OpenAI__Endpoint", _gpt56.Resource.Parent.UriExpression) + .WithEnvironment("DigitalBrain__AI__OpenAI__Gpt56__Model", _gpt56.Resource.Model); } } @@ -119,9 +132,7 @@ private void AddOllamaModel(Type model, string resourceSuffix, string tag) .WithLifetime(ContainerLifetime.Persistent) .WithOpenWebUI(uiContainer => uiContainer.WithLifetime(ContainerLifetime.Persistent)); - _ollamaModels[model] = _ollama.AddModel( - $"{brain.Name}-ai-{resourceSuffix}", - tag); + _ollamaModels[model] = _ollama.AddModel($"{brain.Name}-ai-{resourceSuffix}", tag); } private void AddGpt56() diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs index 134c99b5a..065dabe0d 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/CapabilityToolSelected.cs @@ -4,5 +4,4 @@ namespace DigitalBrain.AI; [GenerateSerializer] [Alias("ai.capability-tool-selected")] -public sealed record CapabilityToolSelected( - [property: Id(0)] string Tool) : Synapse; +public sealed record CapabilityToolSelected([property: Id(0)] string Tool) : Synapse; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs index d34bb21fb..bb939e424 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Contracts/IAgent.cs @@ -7,5 +7,6 @@ namespace DigitalBrain.AI; public partial interface IAgent : INeuron { [Alias(nameof(Respond))] + [ResponseTimeout(NeuronCallTimeouts.LongRunning)] Task Respond(IReadOnlyList messages); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs index 49683a965..79cf8564c 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AIProviderConfiguration.cs @@ -25,11 +25,13 @@ public sealed class AIProviderConfiguration "file:///tmp/ollama", ]; - public static TheoryData ValidOllamaEndpoints => - [ - "http://ollama.example.test:11434", - "https://ollama.example.test:11435", - ]; + public static TheoryData SupportedOllamaModels => new() + { + { typeof(Llama32), "llama3.2", "http://ollama.example.test:11434" }, + { typeof(Gemma4), "gemma4:12b", "https://ollama.example.test:11435" }, + { typeof(Qwen35), "qwen3.5:9b", "http://ollama.example.test:11436" }, + { typeof(Granite41), "granite4.1:8b", "https://ollama.example.test:11437" }, + }; [Theory(DisplayName = "missing, blank, and malformed Ollama endpoints fail before client contact")] [MemberData(nameof(InvalidOllamaEndpoints))] @@ -43,18 +45,19 @@ public void InvalidOllamaEndpointsFailBeforeClientContact(string? endpoint) Assert.Contains(OllamaEndpointKey, exception.Message, StringComparison.Ordinal); } - [Theory(DisplayName = "an explicit HTTP(S) Ollama endpoint resolves only the selected Llama32 client")] - [MemberData(nameof(ValidOllamaEndpoints))] - public void ExplicitOllamaEndpointResolvesSelectedClient(string endpoint) + [Theory(DisplayName = "every supported Ollama model resolves with its expected default tag")] + [MemberData(nameof(SupportedOllamaModels))] + public void SupportedOllamaModelResolvesWithExpectedDefaultTag(Type modelType, string expectedTag, string endpoint) { using var provider = Compose(endpoint); - var client = provider.GetRequiredKeyedService(typeof(Llama32)); - var ollama = Assert.IsAssignableFrom(client); + var client = provider.GetRequiredKeyedService(modelType); + var ollama = Assert.IsAssignableFrom(client.GetService(typeof(IOllamaApiClient))); + Assert.NotSame(ollama, client); Assert.Equal(new Uri(endpoint), ollama.Uri); Assert.False(string.Equals("localhost", ollama.Uri.Host, StringComparison.OrdinalIgnoreCase)); - Assert.Equal("llama3.2", ollama.SelectedModel); + Assert.Equal(expectedTag, ollama.SelectedModel); } private static ServiceProvider Compose(string? endpoint) @@ -86,9 +89,7 @@ private static ServiceProvider Compose(string? endpoint) return services.BuildServiceProvider(); } - private sealed class CompositionSiloBuilder( - IServiceCollection services, - IConfiguration configuration) : ISiloBuilder + private sealed class CompositionSiloBuilder(IServiceCollection services, IConfiguration configuration) : ISiloBuilder { public IServiceCollection Services => services; diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs index 66a669d3a..e9898cb95 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/AISmoke.cs @@ -18,8 +18,7 @@ public async Task TypedLlmReturnsTheScriptedResponse() await using var test = await fixture.CreateBrainAsync(cancellationToken); test.Chat().Reply(ScriptedReply); - var response = await test.Client.Get(ModelName).Respond( - [new ChatMessage(ChatRole.User, UserPrompt)]); + var response = await test.Client.Get(ModelName).Respond([new ChatMessage(ChatRole.User, UserPrompt)]); Assert.Equal(ScriptedReply, response.Text); Assert.Equal(1, test.Chat().CallCount); diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs index 972e5ad1c..5fd85e9a4 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolProbes.cs @@ -9,9 +9,7 @@ namespace DigitalBrain.ModuleTests; [GenerateSerializer] [Alias("moduletests.account-enriched-probe")] -public sealed record ProbeAccountEnriched( - [property: Id(0)] string AccountId, - [property: Id(1)] string MessageId) : Synapse; +public sealed record ProbeAccountEnriched([property: Id(0)] string AccountId, [property: Id(1)] string MessageId) : Synapse; [Alias("DigitalBrain.ModuleTests.IEnrichmentProbe")] [ClientEntryPoint] @@ -35,12 +33,15 @@ public Task Enrich(string accountId, string messageId) } } -public sealed class ToolAgentProbe( - [FromKeyedServices(typeof(Llama32))] IChatClient chatClient) +public sealed class ToolAgentProbe([FromKeyedServices(typeof(Llama32))] IChatClient chatClient) : Agent(chatClient), IToolAgentProbe { public const string EnrichTool = "enrich_account_from_email"; public const string ProbeName = "enrichment"; + public const string ProbeInstructions = + "You are the capability seam probe. Use only the supplied tools."; + + protected override string? Instructions => ProbeInstructions; protected override IReadOnlyList Tools => [ @@ -52,8 +53,7 @@ public sealed class ToolAgentProbe( private async Task EnrichAsync(string accountId, string messageId) { - var enrichment = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, ProbeName).ToGrainId()); + var enrichment = GrainFactory.GetGrain(NeuronId.For(Id.Owner, ProbeName).ToGrainId()); await enrichment.Enrich(accountId, messageId); diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs index f77f7b2be..600584082 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/CapabilityToolSeam.cs @@ -10,8 +10,33 @@ public sealed class CapabilityToolSeam(ModuleFixture fixture) private const string AccountId = "001AAAAAAAAAAAAAAA"; private const string MessageId = "msg-42"; private const string FinalReply = "Account updated."; + private const string IdentityPrompt = "Who are you?"; private const int SeamTimeout = 60_000; + [Fact(Timeout = SeamTimeout, DisplayName = + "agent instructions reach the model as the first system turn")] + public async Task AgentInstructionsReachModelAsFirstSystemTurn() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + test.Chat().Reply(FinalReply); + + await test.Client.Get(AgentName).Respond([new(Microsoft.Extensions.AI.ChatRole.User, IdentityPrompt)]); + + Assert.Collection( + test.Chat().LastMessages, + system => + { + Assert.Equal(Microsoft.Extensions.AI.ChatRole.System, system.Role); + Assert.Equal(ToolAgentProbe.ProbeInstructions, system.Text); + }, + user => + { + Assert.Equal(Microsoft.Extensions.AI.ChatRole.User, user.Role); + Assert.Equal(IdentityPrompt, user.Text); + }); + } + [Fact(Timeout = SeamTimeout, DisplayName = "a model tool call reaches the real neuron capability with the model's own arguments")] public async Task ModelToolCallInvokesNeuronCapability() @@ -69,12 +94,9 @@ public async Task NoCapabilitySelectedWhenModelAnswersDirectly() var agent = test.Neuron(AgentName); test.Chat().Reply(FinalReply); - await test.Client.Get(AgentName).Respond( - [new(Microsoft.Extensions.AI.ChatRole.User, "hello")]); + await test.Client.Get(AgentName).Respond([new(Microsoft.Extensions.AI.ChatRole.User, "hello")]); - var selected = await agent.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var selected = await agent.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Empty(selected); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs index 45c16fd69..90f35ec90 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/DurablePayloadProtectionStartup.cs @@ -22,9 +22,7 @@ public sealed class DurablePayloadProtectionStartup [Theory(DisplayName = "invalid durable protection keys fail during service composition")] [MemberData(nameof(InvalidKeys))] - public void InvalidKeysFailDuringServiceComposition( - string? encodedKey, - Type expectedExceptionType) + public void InvalidKeysFailDuringServiceComposition(string? encodedKey, Type expectedExceptionType) { var result = Compose(encodedKey, resolveProtector: false); @@ -79,16 +77,12 @@ private static CompositionResult Compose(string? encodedKey, bool resolveProtect return new(compositionException, resolutionException); } - private sealed class CompositionSiloBuilder( - IServiceCollection services, - IConfiguration configuration) : ISiloBuilder + private sealed class CompositionSiloBuilder(IServiceCollection services, IConfiguration configuration) : ISiloBuilder { public IServiceCollection Services => services; public IConfiguration Configuration => configuration; } - private sealed record CompositionResult( - Exception? CompositionException, - Exception? ResolutionException); + private sealed record CompositionResult(Exception? CompositionException, Exception? ResolutionException); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs b/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs index 8bc63b336..1822b1939 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI.Tests/OrchestrationL1.cs @@ -32,12 +32,7 @@ public async Task GroupChatSupervisedAttemptsThrow() var workerId = test.Neuron(SupervisedTeam).Id; var taskId = test.Neuron("task-placeholder").Id; var attempt = new AttemptId(Guid.NewGuid()); - var request = new AttemptRequest( - taskId, - workerId, - attempt, - Revision: 0, - new ProbeGoal("supervised")); + var request = new AttemptRequest(taskId, workerId, attempt, Revision: 0, new ProbeGoal("supervised")); var cursor = new AttemptCursor(taskId, workerId, attempt, Revision: 0); await Assert.ThrowsAsync(() => worker.InvokeAccept(request)); @@ -77,8 +72,7 @@ public async Task ParticipantChangeOnSameIdDemandsMigrationOrReset() await orchestration.UseParticipants("left-alt", "right-alt"); - await Assert.ThrowsAsync( - () => orchestration.Respond([User()])); + await Assert.ThrowsAsync(() => orchestration.Respond([User()])); Assert.Equal(Pair, test.Chat().CallCount); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs index d0b0410b7..8cd5ae207 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Agent.cs @@ -18,6 +18,8 @@ protected Agent(IChatClient chatClient) protected abstract IReadOnlyList Tools { get; } + protected virtual string? Instructions => null; + protected static CapabilityTool Capability(string name, string description, Delegate invoke) => new(name, description, invoke); @@ -31,8 +33,14 @@ public async Task Respond(IReadOnlyList messages) Tools = [.. Tools.Select(tool => tool.BindTo(turnScheduler))], ToolMode = ChatToolMode.Auto, }; - - var response = await _toolCallingClient.GetResponseAsync(messages, options); + var instructions = Instructions; + IReadOnlyList request = string.IsNullOrWhiteSpace(instructions) + ? messages + : [new ChatMessage(ChatRole.System, instructions), .. messages]; + + var response = await _toolCallingClient + .GetStreamingResponseAsync(request, options) + .ToChatResponseAsync(); foreach (var selected in SelectedCapabilities(response)) { diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs index 7c0464e26..44074dcf8 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/AIClients.cs @@ -1,4 +1,5 @@ using System.ClientModel; +using System.Diagnostics.CodeAnalysis; using DigitalBrain.AI.Ollama; using DigitalBrain.AI.OpenAI; using Microsoft.Extensions.AI; @@ -12,6 +13,10 @@ namespace DigitalBrain.AI; internal static class AIClients { private const string ConfigurationRoot = "DigitalBrain:AI"; + private const string EnableSensitiveDataKey = + $"{ConfigurationRoot}:Telemetry:EnableSensitiveData"; + private const string TelemetrySource = "DigitalBrain.AI"; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(5); internal static void Add(IServiceCollection services) { @@ -34,7 +39,11 @@ private static void AddOllamaModel(IServiceCollection services, string d typeof(TModel).Name, defaultTag)); - private static OllamaApiClient Ollama( + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The telemetry middleware owns and disposes the inner Ollama client.")] + private static IChatClient Ollama( IConfiguration configuration, string modelName, string defaultTag) @@ -50,8 +59,19 @@ private static OllamaApiClient Ollama( } var tag = configuration[$"{ConfigurationRoot}:Ollama:{modelName}:Model"] ?? defaultTag; + var enableSensitiveData = configuration.GetValue(EnableSensitiveDataKey); - return new OllamaApiClient(endpointUri, tag); + var http = new HttpClient + { + BaseAddress = endpointUri, + Timeout = RequestTimeout, + }; + + return new ChatClientBuilder(new OllamaApiClient(http, tag)) + .UseOpenTelemetry( + sourceName: $"{TelemetrySource}.{modelName}", + configure: telemetry => telemetry.EnableSensitiveData = enableSensitiveData) + .Build(); } private static IChatClient OpenAI(IConfiguration configuration) @@ -60,6 +80,7 @@ private static IChatClient OpenAI(IConfiguration configuration) ?? throw new InvalidOperationException( "Gpt56 requires DigitalBrain:AI:OpenAI:ApiKey. Configure it through AIModule.WithLlm() in AppHost."); var model = configuration[$"{ConfigurationRoot}:OpenAI:Gpt56:Model"] ?? "gpt-5.6"; + var enableSensitiveData = configuration.GetValue(EnableSensitiveDataKey); var options = new OpenAIClientOptions(); if (configuration[$"{ConfigurationRoot}:OpenAI:Endpoint"] is { } endpoint) @@ -67,8 +88,15 @@ private static IChatClient OpenAI(IConfiguration configuration) options.Endpoint = new Uri(endpoint, UriKind.Absolute); } - return new OpenAIClient(new ApiKeyCredential(apiKey), options) + var client = new OpenAIClient(new ApiKeyCredential(apiKey), options) .GetChatClient(model) .AsIChatClient(); + + return new ChatClientBuilder(client) + .UseStreamingUsage() + .UseOpenTelemetry( + sourceName: $"{TelemetrySource}.{nameof(Gpt56)}", + configure: telemetry => telemetry.EnableSensitiveData = enableSensitiveData) + .Build(); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs b/src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs new file mode 100644 index 000000000..910912756 --- /dev/null +++ b/src/modules/ai/DigitalBrain.Modules.AI/Clients/StreamingUsageChatClientExtensions.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.AI; +using System.Text.Json; + +namespace DigitalBrain.AI; + +internal static class StreamingUsageChatClientExtensions +{ + private const string StreamOptionsKey = "stream_options"; + private static readonly JsonDocument StreamOptionsDoc = JsonDocument.Parse("""{"include_usage":true}"""); + + extension(ChatClientBuilder builder) + { + public ChatClientBuilder UseStreamingUsage() + { + return builder.Use(async (messages, options, next, cancellationToken) => + { + options = EnsureStreamOptions(options); + await next(messages, options, cancellationToken); + }); + } + } + + private static ChatOptions EnsureStreamOptions(ChatOptions? options) + { + if (options is null) + { + return new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [StreamOptionsKey] = StreamOptionsDoc.RootElement.Clone(), + }, + }; + } + + var clonedOptions = options.Clone(); + clonedOptions.AdditionalProperties ??= []; + + if (!clonedOptions.AdditionalProperties.ContainsKey(StreamOptionsKey)) + { + clonedOptions.AdditionalProperties[StreamOptionsKey] = StreamOptionsDoc.RootElement.Clone(); + } + + return clonedOptions; + } +} diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs index e561da538..627994fc1 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Gemma4.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Gemma4( - [Llm] IChatClient chatClient) - : LLM(chatClient), IGemma4; +public sealed class Gemma4([Llm] IChatClient chatClient) : LLM(chatClient), IGemma4; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs index 26404d86c..029e814bb 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Granite41.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Granite41( - [Llm] IChatClient chatClient) - : LLM(chatClient), IGranite41; +public sealed class Granite41([Llm] IChatClient chatClient) : LLM(chatClient), IGranite41; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs index 38df5ad46..d9b7b79bb 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Llama32.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Llama32( - [Llm] IChatClient chatClient) - : LLM(chatClient), ILlama32; +public sealed class Llama32([Llm] IChatClient chatClient) : LLM(chatClient), ILlama32; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs index 51532115d..7ba66b79e 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Ollama/Qwen35.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.Ollama; -public sealed class Qwen35( - [Llm] IChatClient chatClient) - : LLM(chatClient), IQwen35; +public sealed class Qwen35([Llm] IChatClient chatClient) : LLM(chatClient), IQwen35; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs b/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs index deda30de7..aac17b392 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/OpenAI/Gpt56.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.AI.OpenAI; -public sealed class Gpt56( - [Llm] IChatClient chatClient) - : LLM(chatClient), IGpt56; +public sealed class Gpt56([Llm] IChatClient chatClient) : LLM(chatClient), IGpt56; diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs index 4270fdee3..9ddedc7a0 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/Concurrent.cs @@ -15,11 +15,7 @@ public abstract class Concurrent : Neuron, IAgent protected Concurrent() { - _directSession = DirectAgentSession.Create( - ServiceProvider, - "ai.concurrent.session", - () => WriteStateAsync(), - Id); + _directSession = DirectAgentSession.Create(ServiceProvider, "ai.concurrent.session", () => WriteStateAsync(), Id); } protected abstract IReadOnlyList Participants { get; } @@ -36,10 +32,6 @@ public Task Respond(IReadOnlyList messages) var shape = DirectOrchestrationShape.CreateConcurrent(GetType(), snapshot); var agent = shape.CreateAgent(GrainFactory, TaskScheduler.Current); - return _directSession.RunAsync( - agent, - shape.Definition, - messages, - CancellationToken.None); + return _directSession.RunAsync(agent, shape.Definition, messages, CancellationToken.None); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs index 66d9dc7a4..73a3720c4 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectAgentSession.cs @@ -49,20 +49,12 @@ internal async Task RunAsync( var session = state.Value is { Length: > 0 } serialized ? await RestoreAsync(agent, serialized, definition, cancellationToken) : await agent.CreateSessionAsync(cancellationToken); - var response = await agent.RunAsync( - messages, - session, - cancellationToken: cancellationToken); - var serializedSession = await agent.SerializeSessionAsync( - session, - cancellationToken: cancellationToken); + var response = await agent.RunAsync(messages, session, cancellationToken: cancellationToken); + var serializedSession = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken); var protectedSession = protector.Protect( Purpose(definition.Fingerprint), Encoding.UTF8.GetBytes(serializedSession.GetRawText())); - var envelope = new DirectAgentSessionEnvelope( - CurrentEnvelopeVersion, - definition, - protectedSession); + var envelope = new DirectAgentSessionEnvelope(CurrentEnvelopeVersion, definition, protectedSession); var previous = state.Value?.ToArray(); state.Value = JsonSerializer.SerializeToUtf8Bytes(envelope); @@ -105,10 +97,7 @@ private async Task RestoreAsync( throw RecoveryRequired(); } - if (!string.Equals( - stored.Definition.Fingerprint, - definition.Fingerprint, - StringComparison.Ordinal)) + if (!string.Equals(stored.Definition.Fingerprint, definition.Fingerprint, StringComparison.Ordinal)) { throw new InvalidOperationException( "The durable direct-agent session is incompatible with the current orchestration definition; an explicit migration or reset is required."); @@ -116,9 +105,7 @@ private async Task RestoreAsync( try { - var sessionBytes = protector.Unprotect( - Purpose(definition.Fingerprint), - stored.ProtectedSession); + var sessionBytes = protector.Unprotect(Purpose(definition.Fingerprint), stored.ProtectedSession); using var sessionJson = JsonDocument.Parse(sessionBytes); return await agent.DeserializeSessionAsync( diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs index 717879450..940f10666 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/DirectOrchestrationShape.cs @@ -32,17 +32,12 @@ private DirectOrchestrationShape( _participants = participants; _buildWorkflow = buildWorkflow; _executionEnvironment = executionEnvironment; - Definition = OrchestrationDefinition.Describe( - orchestrationType, - participants, - identity); + Definition = OrchestrationDefinition.Describe(orchestrationType, participants, identity); } internal OrchestrationDefinition Definition { get; } - internal static Participant[] Snapshot( - NeuronId orchestration, - IReadOnlyList? participants) + internal static Participant[] Snapshot(NeuronId orchestration, IReadOnlyList? participants) { if (participants is null) { @@ -72,31 +67,19 @@ internal static Participant[] Snapshot( return snapshot; } - internal static DirectOrchestrationShape CreateConcurrent( - Type orchestrationType, - IReadOnlyList participants) + internal static DirectOrchestrationShape CreateConcurrent(Type orchestrationType, IReadOnlyList participants) => new( orchestrationType, participants, - new( - "concurrent", - "in-process-concurrent", - "concurrent-default", - static _ => "none"), + new("concurrent", "in-process-concurrent", "concurrent-default", static _ => "none"), static agents => AgentWorkflowBuilder.BuildConcurrent(agents), InProcessExecution.Concurrent); - internal static DirectOrchestrationShape CreateGroupChat( - Type orchestrationType, - IReadOnlyList participants) + internal static DirectOrchestrationShape CreateGroupChat(Type orchestrationType, IReadOnlyList participants) => new( orchestrationType, participants, - new( - "group-chat", - "in-process-lockstep", - "none", - static count => $"round-robin:{count}"), + new("group-chat", "in-process-lockstep", "none", static count => $"round-robin:{count}"), static agents => AgentWorkflowBuilder .CreateGroupChatBuilderWith(team => new RoundRobinGroupChatManager(team) { @@ -106,9 +89,7 @@ internal static DirectOrchestrationShape CreateGroupChat( .Build(), InProcessExecution.Lockstep); - internal AIAgent CreateAgent( - IGrainFactory grains, - TaskScheduler turnScheduler) + internal AIAgent CreateAgent(IGrainFactory grains, TaskScheduler turnScheduler) { ArgumentNullException.ThrowIfNull(grains); ArgumentNullException.ThrowIfNull(turnScheduler); @@ -118,8 +99,6 @@ internal AIAgent CreateAgent( .. _participants.Select(participant => participant.CreateAgent(grains, turnScheduler)), ]; - return _buildWorkflow(agents).AsAIAgent( - id: Definition.HostId, - executionEnvironment: _executionEnvironment); + return _buildWorkflow(agents).AsAIAgent(id: Definition.HostId, executionEnvironment: _executionEnvironment); } } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs index a0436897b..23de989cc 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/GroupChat.cs @@ -11,11 +11,7 @@ public abstract class GroupChat : Neuron, IGroupChat protected GroupChat() { - _directSession = DirectAgentSession.Create( - ServiceProvider, - "ai.group-chat.session", - () => WriteStateAsync(), - Id); + _directSession = DirectAgentSession.Create(ServiceProvider, "ai.group-chat.session", () => WriteStateAsync(), Id); } protected abstract IReadOnlyList Participants { get; } @@ -32,11 +28,7 @@ public Task Respond(IReadOnlyList messages) var shape = DirectOrchestrationShape.CreateGroupChat(GetType(), snapshot); var agent = shape.CreateAgent(GrainFactory, TaskScheduler.Current); - return _directSession.RunAsync( - agent, - shape.Definition, - messages, - CancellationToken.None); + return _directSession.RunAsync(agent, shape.Definition, messages, CancellationToken.None); } public Task Accept(AttemptRequest request) diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs index 78bd7779d..088794c1a 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/MafParticipantAdapter.cs @@ -23,10 +23,7 @@ internal static OrchestrationParticipant Describe(Participant participant) "Reliability", "CA2000:Dispose objects before losing scope", Justification = "NeuronChatClient has empty disposal and is owned by the ChatClientAgent for the agent lifetime.")] - internal static AIAgent Create( - IGrainFactory grains, - NeuronId id, - TaskScheduler turnScheduler) + internal static AIAgent Create(IGrainFactory grains, NeuronId id, TaskScheduler turnScheduler) where TNeuron : INeuron { ArgumentNullException.ThrowIfNull(turnScheduler); diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs index bd01934f2..251391570 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/NeuronChatClient.cs @@ -54,8 +54,7 @@ private static void ObserveFault(Task response) TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); - private static Func, Task> InvocationFor( - INeuron participant) + private static Func, Task> InvocationFor(INeuron participant) { ArgumentNullException.ThrowIfNull(participant); @@ -69,9 +68,7 @@ private static Func, Task> InvocationFo }; } - private static IReadOnlyList Request( - IEnumerable messages, - ChatOptions? options) + private static IReadOnlyList Request(IEnumerable messages, ChatOptions? options) { var request = messages as IReadOnlyList ?? messages.ToArray(); diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs index ac1ba91f0..d6022da8f 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Orchestration/OrchestrationDefinition.cs @@ -7,9 +7,7 @@ namespace DigitalBrain.AI; -internal sealed record OrchestrationParticipant( - string Contract, - NeuronId NeuronId); +internal sealed record OrchestrationParticipant(string Contract, NeuronId NeuronId); internal sealed record OrchestrationDefinition(string Fingerprint) { @@ -36,8 +34,7 @@ internal static OrchestrationDefinition Describe( identity.ManagerName(described.Length), identity.AggregatorName, described); - var fingerprint = Convert.ToHexStringLower( - SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(source))); + var fingerprint = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(source))); return new(fingerprint); } diff --git a/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs b/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs index 628c03813..5def26331 100644 --- a/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs +++ b/src/modules/ai/DigitalBrain.Modules.AI/Tools/TurnBoundFunction.cs @@ -11,9 +11,7 @@ internal sealed class TurnBoundFunction(AIFunction capability, TaskScheduler tur public override JsonElement JsonSchema => capability.JsonSchema; - protected override ValueTask InvokeCoreAsync( - AIFunctionArguments arguments, - CancellationToken cancellationToken) + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => new(Task.Factory.StartNew( () => capability.InvokeAsync(arguments, cancellationToken).AsTask(), cancellationToken, diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs index 32f9b88cd..e15ecced0 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/AssistantAnswered.cs @@ -4,6 +4,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.assistant-answered")] -public sealed record AssistantAnswered( - [property: Id(0)] CommandId CommandId, - [property: Id(1)] string Text) : Synapse; +public sealed record AssistantAnswered([property: Id(0)] CommandId CommandId, [property: Id(1)] string Text) : Synapse; diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs index 53e71db61..e04423486 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTranscript.cs @@ -2,5 +2,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.transcript")] -public sealed record ChatTranscript( - [property: Id(0)] IReadOnlyList Turns); +public sealed record ChatTranscript([property: Id(0)] IReadOnlyList Turns); diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs index d091c246c..d352460fa 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/ChatTurn.cs @@ -2,6 +2,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.turn")] -public sealed record ChatTurn( - [property: Id(0)] bool FromUser, - [property: Id(1)] string Text); +public sealed record ChatTurn([property: Id(0)] bool FromUser, [property: Id(1)] string Text); diff --git a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs index 70a730a7c..a0c1c2eee 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat.Contracts/SendMessage.cs @@ -4,6 +4,4 @@ namespace DigitalBrain.Chat; [GenerateSerializer] [Alias("chat.send-message")] -public sealed record SendMessage( - [property: Id(0)] CommandId CommandId, - [property: Id(1)] string Text); +public sealed record SendMessage([property: Id(0)] CommandId CommandId, [property: Id(1)] string Text); diff --git a/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs b/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs index ffe81e080..cc7ab1630 100644 --- a/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs +++ b/src/modules/chat/DigitalBrain.Modules.Chat/ChatNeuron.cs @@ -14,15 +14,24 @@ internal sealed class ChatNeuron : IEmit, IEmit { + private const string CommandOrderName = "chat.command-order"; + private const string CommandsName = "chat.commands"; private const string TranscriptName = "chat.transcript"; + private const int RememberedCommands = 64; private const int RetainedTurns = 64; + private readonly IDurableList _commandOrder; + private readonly IDurableDictionary _commands; private readonly IDurableList _transcript; + private readonly Serializer _texts; private readonly Serializer _turns; public ChatNeuron() { + _commandOrder = ServiceProvider.GetRequiredKeyedService>(CommandOrderName); + _commands = ServiceProvider.GetRequiredKeyedService>(CommandsName); _transcript = ServiceProvider.GetRequiredKeyedService>(TranscriptName); + _texts = ServiceProvider.GetRequiredService>(); _turns = ServiceProvider.GetRequiredService>(); } @@ -30,8 +39,29 @@ public async Task Send(SendMessage message) { ArgumentNullException.ThrowIfNull(message); ArgumentException.ThrowIfNullOrWhiteSpace(message.Text); + if (message.CommandId.Value == Guid.Empty) + { + throw new ArgumentException( + "The command id cannot be empty.", + nameof(message)); + } + + if (_commands.TryGetValue(message.CommandId.Value, out var serialized)) + { + if (!string.Equals( + _texts.Deserialize(serialized), + message.Text, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "A chat command id cannot be reused with different text."); + } + + return; + } - await RememberAsync(new ChatTurn(FromUser: true, message.Text)); + Remember(message.CommandId, message.Text); + Remember(new ChatTurn(FromUser: true, message.Text)); await EmitAsync(new UserMessaged(message.CommandId, Id, message.Text, Turns())); } @@ -45,7 +75,7 @@ public async Task HandleAsync(AssistantAnswered synapse, CancellationToken cance return; } - await RememberAsync(new ChatTurn(FromUser: false, synapse.Text)); + Remember(new ChatTurn(FromUser: false, synapse.Text)); await EmitAsync(new AssistantResponded(synapse.CommandId, Id, synapse.Text)); } @@ -53,7 +83,19 @@ public async Task HandleAsync(AssistantAnswered synapse, CancellationToken cance private IReadOnlyList Turns() => [.. _transcript.Select(_turns.Deserialize)]; - private async Task RememberAsync(ChatTurn turn) + private void Remember(CommandId commandId, string text) + { + _commands[commandId.Value] = _texts.SerializeToArray(text); + _commandOrder.Add(commandId.Value); + + while (_commandOrder.Count > RememberedCommands) + { + _commands.Remove(_commandOrder[0]); + _commandOrder.RemoveAt(0); + } + } + + private void Remember(ChatTurn turn) { _transcript.Add(_turns.SerializeToArray(turn)); @@ -61,7 +103,5 @@ private async Task RememberAsync(ChatTurn turn) { _transcript.RemoveAt(0); } - - await WriteStateAsync(CancellationToken.None); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs index 7c6e98db7..d5f8d056c 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostLaunch.cs @@ -63,10 +63,7 @@ private static Result ResolveDesktop( $"under '{packageRoot}' or '{packageRoot}/{ShellPackageDirectoryName}'. " + "Use WithFlutterHost() for the pure-Dart host."); - return new Result( - ResolveFlutterCommand(options, configuration), - workDir, - ["run", "-d", deviceTarget]); + return new Result(ResolveFlutterCommand(options, configuration), workDir, ["run", "-d", deviceTarget]); } private static Result ResolveHeadless( @@ -74,9 +71,7 @@ private static Result ResolveHeadless( FlutterHostOptions options, Microsoft.Extensions.Configuration.IConfiguration? configuration) { - var entry = Path.Combine( - packageRoot, - FlutterHostingExtensions.HeadlessHostEntry.Replace('/', Path.DirectorySeparatorChar)); + var entry = Path.Combine(packageRoot, FlutterHostingExtensions.HeadlessHostEntry.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(entry)) { throw new InvalidOperationException( @@ -254,14 +249,12 @@ private static IEnumerable PathSearchDirectories() } // DCP / non-interactive hosts often lack a full interactive User PATH. Merge User+Machine. - foreach (var directory in SplitPath( - Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User))) + foreach (var directory in SplitPath(Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User))) { yield return directory; } - foreach (var directory in SplitPath( - Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine))) + foreach (var directory in SplitPath(Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine))) { yield return directory; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs index 650dd7b87..fc73414bd 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Aspire.Hosting/FlutterHostingExtensions.cs @@ -70,8 +70,7 @@ private static FlutterHostKind HostKindOf() $"Use {nameof(DesktopHost)} or {nameof(HeadlessHost)}."); } - private static FlutterHostingState GetOrCreateState( - DigitalBrainModuleBuilder module) + private static FlutterHostingState GetOrCreateState(DigitalBrainModuleBuilder module) { var state = module.Brain.GetOrAddState( static brain => new FlutterHostingState(brain), @@ -135,9 +134,7 @@ internal void EnsureFlutterHost(FlutterHostKind kind, FlutterHostOptions options } var appHost = brain.GetApplicationBuilder(); - var packageRoot = ResolveFlutterWorkingDirectory( - appHost.AppHostDirectory, - options.WorkingDirectory); + var packageRoot = ResolveFlutterWorkingDirectory(appHost.AppHostDirectory, options.WorkingDirectory); if (!Directory.Exists(packageRoot) || !File.Exists(Path.Combine(packageRoot, "pubspec.yaml"))) { @@ -146,11 +143,7 @@ internal void EnsureFlutterHost(FlutterHostKind kind, FlutterHostOptions options "Pass FlutterHostOptions.WorkingDirectory or place clients/digitalbrain_flutter in the repo."); } - var launch = FlutterHostLaunch.Resolve( - kind, - packageRoot, - options, - appHost.Configuration); + var launch = FlutterHostLaunch.Resolve(kind, packageRoot, options, appHost.Configuration); var resourceName = string.IsNullOrWhiteSpace(options.ResourceName) ? DefaultFlutterResourceName : options.ResourceName; @@ -176,10 +169,7 @@ public override void Apply(IResourceBuilder builder) if (_ui is not null) { - _ui.WithAnnotation(new WaitAnnotation( - builder.Resource, - WaitType.WaitUntilHealthy, - exitCode: 0)); + _ui.WithAnnotation(new WaitAnnotation(builder.Resource, WaitType.WaitUntilHealthy, exitCode: 0)); } } @@ -210,9 +200,7 @@ private static string ResolveUIProjectPath(string appHostDirectory, string? conf return Path.GetFullPath(candidates[0]); } - private static string ResolveFlutterWorkingDirectory( - string appHostDirectory, - string? configured) + private static string ResolveFlutterWorkingDirectory(string appHostDirectory, string? configured) { if (!string.IsNullOrWhiteSpace(configured)) { diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj index d65d252f4..21f09e3b4 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/DigitalBrain.Modules.Flutter.Http.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs index 80a21ddc8..8dffe3bd5 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/LiveProductUINorthbound.cs @@ -23,26 +23,17 @@ public async Task PostOpenSceneAndSseProjectsSceneOpened() Timeout = TimeSpan.FromSeconds(30), }; - using (var health = await http.GetAsync( - new Uri(UIEdgeContract.HealthPath, UriKind.Relative), - cancellationToken)) + using (var health = await http.GetAsync(new Uri(UIEdgeContract.HealthPath, UriKind.Relative), cancellationToken)) { Assert.True( health.IsSuccessStatusCode, $"Product {UIFixture.DefaultUIResourceName} {UIEdgeContract.HealthPath} not OK at {baseAddress}. Start: aspire start --project os/DigitalBrain.OS.AppHost. Status={(int)health.StatusCode}. Override with {UIFixture.UIBaseEnvironmentVariable}."); } - using var streamRequest = new HttpRequestMessage( - HttpMethod.Get, - UIEdgeSse.ShellEvents(shellName, afterSequence: 0)); - using var streamResponse = await http.SendAsync( - streamRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, UIEdgeSse.ShellEvents(shellName, afterSequence: 0)); + using var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); Assert.Equal(HttpStatusCode.OK, streamResponse.StatusCode); - Assert.Equal( - UIEdgeContract.EventStreamContentType, - streamResponse.Content.Headers.ContentType?.MediaType); + Assert.Equal(UIEdgeContract.EventStreamContentType, streamResponse.Content.Headers.ContentType?.MediaType); await using var body = await streamResponse.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(body); @@ -53,10 +44,7 @@ public async Task PostOpenSceneAndSseProjectsSceneOpened() cancellationToken); Assert.Equal(HttpStatusCode.Accepted, openResponse.StatusCode); - var projected = await UIEdgeSse.ReadNextSceneOpenedAsync( - reader, - cancellationToken, - timeout: TimeSpan.FromSeconds(20)); + var projected = await UIEdgeSse.ReadNextSceneOpenedAsync(reader, cancellationToken, timeout: TimeSpan.FromSeconds(20)); Assert.Equal(sceneKey, projected.SceneKey); Assert.Equal(title, projected.Title); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs index abd78d169..9a6d7f749 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeRoundTrip.cs @@ -1,5 +1,6 @@ using System.Net; using DigitalBrain.Abstractions; +using DigitalBrain.Chat; using DigitalBrain.Flutter; using Xunit; @@ -132,28 +133,67 @@ public async Task HttpShellEventsRejectsNegativeAfterSequence() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - private static async Task OpenShellEventStreamAsync( - HttpClient http, - CancellationToken cancellationToken) + [Fact(DisplayName = + "HTTP brain topology projects configured modules and active owner neurons")] + public async Task HttpBrainTopologyProjectsRuntimeTruth() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + var shell = test.Neuron(UIFixture.DefaultShellName); + await test.Client.Get(UIFixture.DefaultShellName).Open( + new OpenScene(CommandId.New(), HomeSceneKey, HomeTitle)); + + await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); + using var http = CreateClient(app); + var topology = await http.GetFromJsonAsync(UIEdgeContract.BrainTopologyPath, cancellationToken); + + Assert.NotNull(topology); + Assert.Contains(topology.Modules, module => module.Id == FlutterModule.Id.Value); + Assert.Contains(topology.Modules, module => module.Id == ChatModule.Id.Value); + Assert.Contains(topology.Neurons, neuron => neuron.Id == shell.Id.ToString()); + Assert.All(topology.Neurons, neuron => Assert.StartsWith("cluster-", neuron.Placement, StringComparison.Ordinal)); + } + + [Fact(DisplayName = + "SSE chat turn carries neuron, caller, command, correlation, and timestamp for live brain pulse")] + public async Task HttpChatTurnCarriesPulseIdentity() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var test = await fixture.CreateBrainAsync(cancellationToken); + var chat = test.Neuron("pulse"); + + await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); + using var http = CreateClient(app, streaming: true); + await using var events = await OpenChatEventStreamAsync(http, "pulse", cancellationToken); + + var command = new SendMessage(CommandId.New(), "hello"); + await test.Client.Get("pulse").Send(command); + var projected = await UIEdgeSse.ReadNextChatTurnAsync(events.Reader, cancellationToken); + + Assert.True(projected.Sequence > 0); + Assert.Equal(nameof(UserMessaged), projected.Synapse); + Assert.Equal(chat.Id.ToString(), projected.NeuronId); + Assert.Equal(chat.Id.ToString(), projected.Caller); + Assert.Equal(command.CommandId.ToString(), projected.CommandId); + Assert.True(Guid.TryParse(projected.CorrelationId, out var correlation)); + Assert.NotEqual(Guid.Empty, correlation); + Assert.NotEqual(default, projected.Timestamp); + } + + private static async Task OpenShellEventStreamAsync(HttpClient http, CancellationToken cancellationToken) { using var streamRequest = new HttpRequestMessage( HttpMethod.Get, UIEdgeSse.ShellEvents(UIFixture.DefaultShellName, afterSequence: 0)); - var streamResponse = await http.SendAsync( - streamRequest, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); + var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (streamResponse.StatusCode != HttpStatusCode.OK) { var errorBody = await streamResponse.Content.ReadAsStringAsync(cancellationToken); streamResponse.Dispose(); - Assert.Fail( - $"SSE status {(int)streamResponse.StatusCode} {streamResponse.StatusCode}: {errorBody}"); + Assert.Fail($"SSE status {(int)streamResponse.StatusCode} {streamResponse.StatusCode}: {errorBody}"); } - Assert.Equal( - UIEdgeContract.EventStreamContentType, - streamResponse.Content.Headers.ContentType?.MediaType); + Assert.Equal(UIEdgeContract.EventStreamContentType, streamResponse.Content.Headers.ContentType?.MediaType); Assert.Contains( UIEdgeContract.CacheControlNoCache, streamResponse.Headers.CacheControl?.ToString() ?? string.Empty, @@ -163,6 +203,15 @@ private static async Task OpenShellEventStreamAsync( return new ShellEventStream(streamResponse, body); } + private static async Task OpenChatEventStreamAsync(HttpClient http, string chatName, CancellationToken cancellationToken) + { + using var streamRequest = new HttpRequestMessage(HttpMethod.Get, UIEdgeSse.ChatEvents(chatName, afterSequence: 0)); + var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + Assert.Equal(HttpStatusCode.OK, streamResponse.StatusCode); + var body = await streamResponse.Content.ReadAsStreamAsync(cancellationToken); + return new ShellEventStream(streamResponse, body); + } + private static HttpClient CreateClient(WebApplication app, bool streaming = false) { var http = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs index 04de7e24c..0de5e0a2b 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeSse.cs @@ -20,6 +20,9 @@ public static string ActivateControl(string sceneKey, string controlId) => .Replace("{sceneKey}", sceneKey, StringComparison.Ordinal) .Replace("{controlId}", controlId, StringComparison.Ordinal); + public static string ChatEvents(string chatName, long afterSequence = 0) => + $"{UIEdgeContract.ChatEventsPath.Replace("{chatName}", chatName, StringComparison.Ordinal)}?{UIEdgeContract.AfterSequenceQuery}={afterSequence}"; + public static async Task ReadNextSceneOpenedAsync( StreamReader reader, CancellationToken cancellationToken, @@ -97,4 +100,59 @@ public static async Task ReadNextSceneOpenedPayloadAsync( throw new TimeoutException( "SSE stream ended before a SceneOpened projection arrived — brain may have moved while UI projection is dead."); } + + public static async Task ReadNextChatTurnAsync( + StreamReader reader, + CancellationToken cancellationToken) + { + var payload = await ReadNextEventPayloadAsync(reader, UIEdgeContract.ChatTurnEvent, cancellationToken); + return JsonSerializer.Deserialize(payload, EventJsonOptions) + ?? throw new InvalidOperationException("SSE chat-turn payload did not deserialize."); + } + + private static async Task ReadNextEventPayloadAsync( + StreamReader reader, + string expectedEvent, + CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linked.CancelAfter(TimeSpan.FromSeconds(15)); + + string? dataLine = null; + string? eventName = null; + while (!linked.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(linked.Token); + if (line is null) + { + break; + } + + if (line.StartsWith(':', StringComparison.Ordinal)) + { + continue; + } + + if (line.StartsWith("event:", StringComparison.Ordinal)) + { + eventName = line["event:".Length..].Trim(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + dataLine = line["data:".Length..].Trim(); + continue; + } + + if (line.Length == 0 + && string.Equals(eventName, expectedEvent, StringComparison.Ordinal) + && dataLine is not null) + { + return dataLine; + } + } + + throw new TimeoutException($"SSE stream ended before a {expectedEvent} projection arrived."); + } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs index 5e23d638d..0b54a613f 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIEdgeVocabulary.cs @@ -31,16 +31,14 @@ public void PublicEdgeVocabularyIsUIEdgeContract() or "IFlutter"); Assert.Equal("/health", UIEdgeContract.HealthPath); - Assert.Equal("healthy", UIEdgeContract.HealthResponse); Assert.Equal("/shells/{shellName}/scenes", UIEdgeContract.OpenScenePath); Assert.Equal("/shells/{shellName}/events", UIEdgeContract.ShellEventsPath); - Assert.Equal( - "/scenes/{sceneKey}/controls/{controlId}/activate", - UIEdgeContract.ActivateControlPath); + Assert.Equal("/scenes/{sceneKey}/controls/{controlId}/activate", UIEdgeContract.ActivateControlPath); Assert.Equal("afterSequence", UIEdgeContract.AfterSequenceQuery); Assert.Equal("text/event-stream", UIEdgeContract.EventStreamContentType); Assert.Equal("no-cache", UIEdgeContract.CacheControlNoCache); Assert.Equal("scene-opened", UIEdgeContract.SceneOpenedEvent); + Assert.Equal("/brain/topology", UIEdgeContract.BrainTopologyPath); } [Fact(DisplayName = @@ -98,13 +96,8 @@ public async Task MapUIHostServesHealth() await using var app = await UIFixture.StartUIEdgeAsync(test, cancellationToken); using var http = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; - using var health = await http.GetAsync( - new Uri(UIEdgeContract.HealthPath, UriKind.Relative), - cancellationToken); + using var health = await http.GetAsync(new Uri(UIEdgeContract.HealthPath, UriKind.Relative), cancellationToken); Assert.Equal(HttpStatusCode.OK, health.StatusCode); - Assert.Equal( - $"\"{UIEdgeContract.HealthResponse}\"", - (await health.Content.ReadAsStringAsync(cancellationToken)).Trim()); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs index 5f10e8b39..5d80daf3c 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http.Tests/UIFixture.cs @@ -1,5 +1,7 @@ +using DigitalBrain.Chat; using DigitalBrain.Flutter; using DigitalBrain.Flutter.Aspire.Hosting; +using DigitalBrain.ServiceDefaults; using DigitalBrain.Testing; namespace DigitalBrain.UI.Tests; @@ -23,19 +25,25 @@ public static Uri ResolveProductUIBaseAddress() return new Uri(configured.TrimEnd('/') + "/"); } - public static async Task StartUIEdgeAsync( - TestBrain test, - CancellationToken cancellationToken) + public static async Task StartUIEdgeAsync(TestBrain test, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(test); var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.AddServiceDefaults(); + builder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["DigitalBrain:Modules:0"] = FlutterModule.Id.Value, + ["DigitalBrain:Modules:1"] = ChatModule.Id.Value, + }); builder.Services.AddSingleton(test.Client); builder.Services.AddSingleton(test.Cluster.Client); builder.Services.AddUIEdgeServices(); var app = builder.Build(); + app.MapDefaultEndpoints(); app.MapUIHost(); await app.StartAsync(cancellationToken); return app; @@ -47,5 +55,6 @@ protected override void Configure(DigitalBrainTestBuilder brain) { ArgumentNullException.ThrowIfNull(brain); brain.AddModule(); + brain.AddModule(); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs new file mode 100644 index 000000000..c47c4e455 --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainEndpoints.cs @@ -0,0 +1,22 @@ +namespace DigitalBrain.UI; + +internal static class BrainEndpoints +{ + public static IEndpointRouteBuilder MapBrain(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + endpoints.MapGet( + UIEdgeContract.BrainTopologyPath, + static async Task ( + BrainTopologyReader topology, + CancellationToken cancellationToken) => + { + ArgumentNullException.ThrowIfNull(topology); + var snapshot = await topology.ReadAsync(cancellationToken); + return Results.Ok(snapshot); + }); + + return endpoints; + } +} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs new file mode 100644 index 000000000..983816a04 --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyModels.cs @@ -0,0 +1,10 @@ +namespace DigitalBrain.UI; + +internal sealed record BrainTopologySnapshot( + IReadOnlyList Modules, + IReadOnlyList Neurons, + DateTimeOffset ObservedAt); + +internal sealed record BrainModule(string Id); + +internal sealed record BrainNeuron(string Id, string GrainType, string Identity, string Placement); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs new file mode 100644 index 000000000..e60e80f7a --- /dev/null +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/BrainTopologyReader.cs @@ -0,0 +1,52 @@ +using DigitalBrain.Client; + +namespace DigitalBrain.UI; + +internal sealed class BrainTopologyReader(IDigitalBrain brain, IGrainFactory grains, IConfiguration configuration) +{ + public async Task ReadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var modules = configuration + .GetSection("DigitalBrain:Modules") + .GetChildren() + .Select(section => section.Value) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => new BrainModule(value!)) + .OrderBy(static module => module.Id, StringComparer.Ordinal) + .ToArray(); + + var statistics = await grains + .GetGrain(0) + .GetDetailedGrainStatistics(); + cancellationToken.ThrowIfCancellationRequested(); + + var ownerPrefix = $"{brain.Owner.Value}/"; + var ownerStatistics = statistics + .Where(statistic => statistic.GrainId.Key.ToString()! + .StartsWith(ownerPrefix, StringComparison.Ordinal)) + .ToArray(); + var placements = ownerStatistics + .Select(static statistic => statistic.SiloAddress) + .Distinct() + .OrderBy(static address => address.ToString(), StringComparer.Ordinal) + .Select(static (address, index) => (Address: address, Label: $"cluster-{index + 1}")) + .ToDictionary(static placement => placement.Address, static placement => placement.Label); + var neurons = ownerStatistics + .Select(statistic => + { + var type = statistic.GrainId.Type.ToString()!; + var key = statistic.GrainId.Key.ToString()!; + return new BrainNeuron($"{type}:{key}", type, key, placements[statistic.SiloAddress]); + }) + .OrderBy(static neuron => neuron.GrainType, StringComparer.Ordinal) + .ThenBy(static neuron => neuron.Identity, StringComparer.Ordinal) + .ToArray(); + + return new BrainTopologySnapshot( + modules, + neurons, + TimeProvider.System.GetUtcNow()); + } +} diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs index b6214baaa..261980609 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatEventFeed.cs @@ -64,7 +64,12 @@ private static IEnumerable ProjectTurns(JournalRead batch) delivery.Sequence, FromUser: true, messaged.Text, - messaged.CommandId.ToString()); + messaged.CommandId.ToString(), + nameof(UserMessaged), + messaged.Chat.ToString(), + delivery.Caller.ToString(), + delivery.CorrelationId.ToString(), + delivery.Timestamp); break; case AssistantResponded responded: @@ -72,27 +77,25 @@ private static IEnumerable ProjectTurns(JournalRead batch) delivery.Sequence, FromUser: false, responded.Text, - responded.CommandId.ToString()); + responded.CommandId.ToString(), + nameof(AssistantResponded), + responded.Chat.ToString(), + delivery.Caller.ToString(), + delivery.CorrelationId.ToString(), + delivery.Timestamp); break; } } } - private static Task WriteEventAsync( - Stream responseBody, - ChatTurnEvent turn, - CancellationToken cancellationToken) + private static Task WriteEventAsync(Stream responseBody, ChatTurnEvent turn, CancellationToken cancellationToken) { var payload = JsonSerializer.Serialize(turn, EventJson); - var frame = FormattableString.Invariant( - $"id: {turn.Sequence}\nevent: {UIEdgeContract.ChatTurnEvent}\ndata: {payload}\n\n"); + var frame = FormattableString.Invariant($"id: {turn.Sequence}\nevent: {UIEdgeContract.ChatTurnEvent}\ndata: {payload}\n\n"); return WriteAsync(responseBody, frame, cancellationToken); } - private static async Task WriteAsync( - Stream responseBody, - string text, - CancellationToken cancellationToken) + private static async Task WriteAsync(Stream responseBody, string text, CancellationToken cancellationToken) { var bytes = Utf8.GetBytes(text); await responseBody.WriteAsync(bytes, cancellationToken); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs index f68b7c497..705c95605 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ChatTurnEvent.cs @@ -4,4 +4,9 @@ internal sealed record ChatTurnEvent( long Sequence, bool FromUser, string Text, - string CommandId); + string CommandId, + string Synapse, + string NeuronId, + string Caller, + string CorrelationId, + DateTimeOffset Timestamp); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj index 3ed4a5fca..ee17f53af 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/DigitalBrain.Modules.Flutter.Http.csproj @@ -11,6 +11,7 @@ + diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs index 91a049b4b..b8f801f01 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/OwnerSessionJournal.cs @@ -20,9 +20,7 @@ public static OwnerSessionJournal Open(IGrainFactory grains, OwnerId owner) { ArgumentNullException.ThrowIfNull(grains); - return new OwnerSessionJournal( - grains.GetGrain(ISessionNeuron.ForOwner(owner).ToGrainId()), - owner); + return new OwnerSessionJournal(grains.GetGrain(ISessionNeuron.ForOwner(owner).ToGrainId()), owner); } public Task ReadShellOutgoingAsync(string shellName, long afterSequence) @@ -30,10 +28,7 @@ public Task ReadShellOutgoingAsync(string shellName, long afterSequ ArgumentException.ThrowIfNullOrWhiteSpace(shellName); ArgumentOutOfRangeException.ThrowIfNegative(afterSequence); - return _session.ReadNeuronJournal( - NeuronId.For(_owner, shellName), - JournalKind.Outgoing, - afterSequence); + return _session.ReadNeuronJournal(NeuronId.For(_owner, shellName), JournalKind.Outgoing, afterSequence); } public Task ReadChatOutgoingAsync(string chatName, long afterSequence) @@ -41,9 +36,6 @@ public Task ReadChatOutgoingAsync(string chatName, long afterSequen ArgumentException.ThrowIfNullOrWhiteSpace(chatName); ArgumentOutOfRangeException.ThrowIfNegative(afterSequence); - return _session.ReadNeuronJournal( - NeuronId.For(_owner, chatName), - JournalKind.Outgoing, - afterSequence); + return _session.ReadNeuronJournal(NeuronId.For(_owner, chatName), JournalKind.Outgoing, afterSequence); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs index c658e4e8d..df3c272b3 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/Program.cs @@ -1,12 +1,15 @@ using DigitalBrain.Aspire; +using DigitalBrain.ServiceDefaults; using DigitalBrain.UI; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); builder.AddKeyedAzureTableServiceClient("brain-clustering"); builder.AddDigitalBrainClient(); builder.Services.AddUIEdgeServices(); var app = builder.Build(); +app.MapDefaultEndpoints(); app.MapUIHost(); app.Run(); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs index 65b9d7fd1..7134e14ee 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/SceneOpenedEvent.cs @@ -1,8 +1,3 @@ namespace DigitalBrain.UI; -internal sealed record SceneOpenedEvent( - long Sequence, - string SceneKey, - string Title, - string CommandId, - string Shell); +internal sealed record SceneOpenedEvent(long Sequence, string SceneKey, string Title, string CommandId, string Shell); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs index 84e436182..0a49fc6eb 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/ShellEventFeed.cs @@ -46,21 +46,14 @@ public static async Task WriteSceneOpenedSseAsync( } } - private static Task WriteEventAsync( - Stream responseBody, - SceneOpenedEvent projected, - CancellationToken cancellationToken) + private static Task WriteEventAsync(Stream responseBody, SceneOpenedEvent projected, CancellationToken cancellationToken) { var payload = JsonSerializer.Serialize(projected, EventJson); - var frame = FormattableString.Invariant( - $"id: {projected.Sequence}\nevent: {UIEdgeContract.SceneOpenedEvent}\ndata: {payload}\n\n"); + var frame = FormattableString.Invariant($"id: {projected.Sequence}\nevent: {UIEdgeContract.SceneOpenedEvent}\ndata: {payload}\n\n"); return WriteAsync(responseBody, frame, cancellationToken); } - private static async Task WriteAsync( - Stream responseBody, - string text, - CancellationToken cancellationToken) + private static async Task WriteAsync(Stream responseBody, string text, CancellationToken cancellationToken) { var bytes = Utf8.GetBytes(text); await responseBody.WriteAsync(bytes, cancellationToken); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs index c31a3aa5d..ae392b8f6 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeContract.cs @@ -10,8 +10,6 @@ public static class UIEdgeContract { public const string HealthPath = "/health"; - public const string HealthResponse = "healthy"; - public const string OpenScenePath = "/shells/{shellName}/scenes"; public const string ShellEventsPath = "/shells/{shellName}/events"; @@ -31,4 +29,6 @@ public static class UIEdgeContract public const string ChatEventsPath = "/chats/{chatName}/events"; public const string ChatTurnEvent = "chat-turn"; + + public const string BrainTopologyPath = "/brain/topology"; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs index a97a0b62b..e0abd393d 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEdgeServices.cs @@ -13,6 +13,7 @@ public static IServiceCollection AddUIEdgeServices(this IServiceCollection servi OwnerSessionJournal.Open( services.GetRequiredService(), services.GetRequiredService().Owner)); + services.TryAddSingleton(); return services; } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs index 455e2195a..737fc9c5b 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIEndpoints.cs @@ -26,10 +26,7 @@ static async Task ( cancellationToken.ThrowIfCancellationRequested(); var shell = brain.Get(shellName); - await shell.Open(new OpenScene( - CommandId.New(), - request.SceneKey, - request.Title)); + await shell.Open(new OpenScene(CommandId.New(), request.SceneKey, request.Title)); return Results.Accepted(); }); @@ -87,9 +84,7 @@ static async Task ( return Results.BadRequest(); } - await brain.SendAsync( - sceneKey, - new ControlActivated(sceneKey, controlId, request.Intent)); + await brain.SendAsync(sceneKey, new ControlActivated(sceneKey, controlId, request.Intent)); return Results.Accepted(); }); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs index a818c5352..77c54678d 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Http/UIHost.cs @@ -6,9 +6,9 @@ public static WebApplication MapUIHost(this WebApplication app) { ArgumentNullException.ThrowIfNull(app); - app.MapGet(UIEdgeContract.HealthPath, static () => Results.Ok(UIEdgeContract.HealthResponse)); app.MapUI(); app.MapChat(); + app.MapBrain(); return app; } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs index f40d23c09..0b0343596 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/FlutterVocabulary.cs @@ -51,16 +51,12 @@ public void ShellAndSceneMethodShapes() Assert.All(shellMethods, method => { Assert.DoesNotContain("Async", method.Name, StringComparison.Ordinal); - Assert.Equal( - method.Name, - method.GetCustomAttribute()?.Alias); + Assert.Equal(method.Name, method.GetCustomAttribute()?.Alias); Assert.Equal(typeof(Task), method.ReturnType); }); var open = shellMethods.Single(); - Assert.Equal( - [typeof(OpenScene)], - open.GetParameters().Select(parameter => parameter.ParameterType)); + Assert.Equal([typeof(OpenScene)], open.GetParameters().Select(parameter => parameter.ParameterType)); Assert.Contains(typeof(INeuron), typeof(IShell).GetInterfaces()); Assert.Contains(typeof(INeuron), typeof(IScene).GetInterfaces()); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs index f2abb517c..a904919fe 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter.Tests/ShellSceneRoundTrip.cs @@ -11,10 +11,7 @@ public async Task OpenJournalsSceneOpenedOnTheShell() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var shell = test.Neuron(FlutterFixture.ShellName); - var command = new OpenScene( - CommandId.New(), - FlutterFixture.HomeSceneKey, - FlutterFixture.HomeSceneTitle); + var command = new OpenScene(CommandId.New(), FlutterFixture.HomeSceneKey, FlutterFixture.HomeSceneTitle); await shell.Reference.Open(command); @@ -61,18 +58,12 @@ await shell.Reference.Open(new OpenScene( var opened = await shell.Outgoing.NextAsync(cancellationToken); Assert.Equal(FlutterFixture.HomeSceneKey, opened.Synapse.SceneKey); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Incoming.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await shell.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Incoming.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await shell.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = @@ -85,27 +76,14 @@ public async Task OpenRejectsBlankSceneKeyAndTitle() var commandId = CommandId.New(); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - string.Empty, - FlutterFixture.HomeSceneTitle))); + shell.Reference.Open(new OpenScene(commandId, string.Empty, FlutterFixture.HomeSceneTitle))); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - " ", - FlutterFixture.HomeSceneTitle))); + shell.Reference.Open(new OpenScene(commandId, " ", FlutterFixture.HomeSceneTitle))); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - FlutterFixture.HomeSceneKey, - string.Empty))); + shell.Reference.Open(new OpenScene(commandId, FlutterFixture.HomeSceneKey, string.Empty))); await Assert.ThrowsAsync(() => - shell.Reference.Open(new OpenScene( - commandId, - FlutterFixture.HomeSceneKey, - " "))); + shell.Reference.Open(new OpenScene(commandId, FlutterFixture.HomeSceneKey, " "))); - Assert.Empty(await shell.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await shell.Outgoing.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs index 010194302..fa65df5d5 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter/SceneNeuron.cs @@ -9,9 +9,7 @@ internal sealed class SceneNeuron : IScene, IHandle { - public Task HandleAsync( - ControlActivated synapse, - CancellationToken cancellationToken) + public Task HandleAsync(ControlActivated synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); ArgumentException.ThrowIfNullOrWhiteSpace(synapse.SceneKey); diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs index a696d534d..b61ea3b52 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellBootNeuron.cs @@ -12,9 +12,7 @@ internal sealed class ShellBootNeuron : public const string HomeSceneKey = "home"; public const string HomeSceneTitle = "Home"; - public Task HandleAsync( - DigitalBrainActivated synapse, - CancellationToken cancellationToken) + public Task HandleAsync(DigitalBrainActivated synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); @@ -24,12 +22,8 @@ public Task HandleAsync( return Task.CompletedTask; } - var shell = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, DefaultShellName).ToGrainId()); + var shell = GrainFactory.GetGrain(NeuronId.For(Id.Owner, DefaultShellName).ToGrainId()); - return shell.Open(new OpenScene( - CommandId.New(), - HomeSceneKey, - HomeSceneTitle)); + return shell.Open(new OpenScene(CommandId.New(), HomeSceneKey, HomeSceneTitle)); } } diff --git a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs index c8e6b61fb..ba295e658 100644 --- a/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs +++ b/src/modules/flutter/DigitalBrain.Modules.Flutter/ShellNeuron.cs @@ -15,10 +15,6 @@ public Task Open(OpenScene command) ArgumentException.ThrowIfNullOrWhiteSpace(command.SceneKey); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); - return EmitAsync(new SceneOpened( - command.CommandId, - Id, - command.SceneKey, - command.Title)); + return EmitAsync(new SceneOpened(command.CommandId, Id, command.SceneKey, command.Title)); } } diff --git a/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs b/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs index afb87f254..cd7078614 100644 --- a/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs +++ b/src/modules/google/DigitalBrain.Modules.Google.Aspire.Hosting/GoogleHostingExtensions.cs @@ -14,8 +14,7 @@ public static class GoogleHostingExtensions "OAuth client secret from [Google Auth Platform](https://console.cloud.google.com/auth/clients).", "OAuth callback URI registered on the Google client. Use an HTTP loopback callback only with the explicit local development authorization mode."); - public static DigitalBrainModuleBuilder WithGmail( - this DigitalBrainModuleBuilder module) + public static DigitalBrainModuleBuilder WithGmail(this DigitalBrainModuleBuilder module) { ArgumentNullException.ThrowIfNull(module); diff --git a/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs b/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs index fe7c240dc..d9f9d125d 100644 --- a/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs +++ b/src/modules/google/DigitalBrain.Modules.Google.Contracts/Gmail/IGmail.cs @@ -5,7 +5,5 @@ namespace DigitalBrain.Google; public partial interface IGmail : INeuron { [Alias(nameof(ReadMessage))] - Task ReadMessage( - string messageId, - CancellationToken cancellationToken); + Task ReadMessage(string messageId, CancellationToken cancellationToken); } diff --git a/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs b/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs index 13878a447..bd12f78f0 100644 --- a/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs +++ b/src/modules/google/DigitalBrain.Modules.Google/Gmail/Gmail.cs @@ -27,9 +27,7 @@ public Gmail(McpRuntime runtime) _durableIdentity = Id.ToString(); } - public async Task ReadMessage( - string messageId, - CancellationToken cancellationToken) + public async Task ReadMessage(string messageId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(messageId); diff --git a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs index f53fc0af5..c25c1ed8c 100644 --- a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs +++ b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Approve/Approve.cs @@ -91,9 +91,7 @@ public async Task ApproveAccountDescriptio return Receipt(mutation); } - private static void EnsureSameApproval( - MutationData mutation, - SalesforceMutationApproval approval) + private static void EnsureSameApproval(MutationData mutation, SalesforceMutationApproval approval) { if (mutation.Approval != approval) { @@ -118,22 +116,16 @@ private static void ValidateApprovalEvidence( } } - private void ValidateApproval( - SalesforceMutationApproval approval, - SynapseDelivery approvalEvidence) + private void ValidateApproval(SalesforceMutationApproval approval, SynapseDelivery approvalEvidence) { if (approval.CommandId == default) { - throw new ArgumentException( - "A mutation command identity is required.", - nameof(approval)); + throw new ArgumentException("A mutation command identity is required.", nameof(approval)); } if (approval.ApprovalId == Guid.Empty || approvalEvidence.SynapseId == default) { - throw new ArgumentException( - "Durable approval identity and evidence are required.", - nameof(approval)); + throw new ArgumentException("Durable approval identity and evidence are required.", nameof(approval)); } ArgumentException.ThrowIfNullOrWhiteSpace(approval.Fingerprint); diff --git a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs index f74d7ecf5..70e9258ff 100644 --- a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs +++ b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Invoke/Invoke.cs @@ -7,9 +7,7 @@ namespace DigitalBrain.Salesforce; internal sealed partial class Salesforce { - private async Task InvokeUpdateAsync( - MutationData mutation, - CancellationToken cancellationToken) + private async Task InvokeUpdateAsync(MutationData mutation, CancellationToken cancellationToken) => await _runtime.RunAsync( Server, _tokenState, @@ -32,10 +30,7 @@ private async Task InvokeUpdateAsync( var result = await updateTool.Tool.CallAsync( UpdateArguments(mutation), cancellationToken: callbackCancellation); - var content = McpRuntime.RequireStructuredContent( - result, - Server, - UpdateAccountName); + var content = McpRuntime.RequireStructuredContent(result, Server, UpdateAccountName); return mutation with { @@ -72,9 +67,7 @@ private async Task ReconcileBoundedAsync(MutationData mutation) "Design", "CA1031:Do not catch general exception types", Justification = "Reconciliation is best effort; inability to prove the provider state must durably become OutcomeUncertain.")] - private async Task ReconcileAsync( - MutationData mutation, - CancellationToken cancellationToken) + private async Task ReconcileAsync(MutationData mutation, CancellationToken cancellationToken) { try { @@ -196,10 +189,7 @@ private static SelectedTool SelectQueryTool(IList tools) => idempotent: true, openWorld: false)); - private static SelectedTool SelectTool( - IList tools, - string name, - Func compatible) + private static SelectedTool SelectTool(IList tools, string name, Func compatible) { var matches = tools .Where(candidate => string.Equals(candidate.Name, name, StringComparison.Ordinal)) @@ -228,9 +218,7 @@ private static SelectedTool SelectTool( annotations?.OpenWorldHint)); } - private static bool HasExactObjectSchema( - JsonElement? schema, - params (string Name, string Type)[] expected) + private static bool HasExactObjectSchema(JsonElement? schema, params (string Name, string Type)[] expected) { if (schema is not { ValueKind: JsonValueKind.Object } value || !value.TryGetProperty("type", out var schemaType) diff --git a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs index 43cc0cef5..c940a53cc 100644 --- a/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs +++ b/src/modules/salesforce/DigitalBrain.Modules.Salesforce/Salesforce.cs @@ -29,8 +29,7 @@ internal sealed partial class Salesforce : Neuron, ISalesforce public Salesforce(McpRuntime runtime) { _runtime = runtime; - _mutations = ServiceProvider.GetRequiredKeyedService>( - MutationsName); + _mutations = ServiceProvider.GetRequiredKeyedService>(MutationsName); _states = ServiceProvider.GetRequiredService>(); _tokenState = ServiceProvider.GetRequiredKeyedService>(TokensName); _durableIdentity = Id.ToString(); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs index b2fdfb798..9476d76f7 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Cancel.cs @@ -10,10 +10,7 @@ public async Task CancelMovesThroughCancellingToCancelled() { var cancellationToken = TestContext.Current.CancellationToken; await using var brain = await fixture.CreateBrainAsync(cancellationToken); - var (worker, task, started) = await StartAsync( - brain, - "cancel", - new TestGoal("cancel-me")); + var (worker, task, started) = await StartAsync(brain, "cancel", new TestGoal("cancel-me")); var running = await AcceptThenRunningAsync(task, cancellationToken); var cancel = new CancelTask(CommandId.New(), running.Revision); @@ -24,19 +21,10 @@ public async Task CancelMovesThroughCancellingToCancelled() Assert.Equal(running.ActiveAttempt, cancelling.ActiveAttempt); AssertReceipt(cancelling, await task.Reference.Cancel(cancel)); - var cancelledFact = await task.Incoming.NextAsync( - cancellationToken); - AssertAttempt( - cancelledFact, - task.Id, - worker.Id, - running.ActiveAttempt, - running.Revision); + var cancelledFact = await task.Incoming.NextAsync(cancellationToken); + AssertAttempt(cancelledFact, task.Id, worker.Id, running.ActiveAttempt, running.Revision); - var cancelled = await WaitForStateAsync( - task, - TaskState.Cancelled, - cancellationToken); + var cancelled = await WaitForStateAsync(task, TaskState.Cancelled, cancellationToken); Assert.Null(cancelled.ActiveAttempt); Assert.Null(cancelled.Blocker); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs index 47c0aef85..9b013579a 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Outcomes.cs @@ -13,28 +13,14 @@ public async Task MatchingAttemptSucceededMovesTaskToSucceededWithResultAndEvide var (worker, task, started) = await StartAsync(brain, "success", goal); var accepted = await task.Incoming.NextAsync(cancellationToken); - AssertAttempt( - accepted, - task.Id, - worker.Id, - started.ActiveAttempt, - started.Revision); - - var succeededFact = await task.Incoming.NextAsync( - cancellationToken); - AssertAttempt( - succeededFact, - task.Id, - worker.Id, - started.ActiveAttempt, - started.Revision); + AssertAttempt(accepted, task.Id, worker.Id, started.ActiveAttempt, started.Revision); + + var succeededFact = await task.Incoming.NextAsync(cancellationToken); + AssertAttempt(succeededFact, task.Id, worker.Id, started.ActiveAttempt, started.Revision); Assert.Equal(TaskFixtures.Done, succeededFact.Synapse.Result); Assert.NotEmpty(succeededFact.Synapse.Evidence); - var succeeded = await WaitForStateAsync( - task, - TaskState.Succeeded, - cancellationToken); + var succeeded = await WaitForStateAsync(task, TaskState.Succeeded, cancellationToken); Assert.Null(succeeded.ActiveAttempt); Assert.Null(succeeded.Blocker); @@ -50,10 +36,7 @@ public async Task StaleRevisionAttemptFactsAreIgnored() { var cancellationToken = TestContext.Current.CancellationToken; await using var brain = await fixture.CreateBrainAsync(cancellationToken); - var (_, task, started) = await StartAsync( - brain, - "stale", - new StaleProbeGoal("stale")); + var (_, task, started) = await StartAsync(brain, "stale", new StaleProbeGoal("stale")); var running = await AcceptThenRunningAsync(task, cancellationToken); var stale = await task.Incoming.NextAsync(cancellationToken); @@ -81,8 +64,7 @@ public async Task RetryableAttemptFailedSchedulesRetryThenNewAccept() new RetryableFailureGoal("retry-me"), TaskFixtures.TwoAttempts); - var firstAccepted = await task.Incoming.NextAsync( - cancellationToken); + var firstAccepted = await task.Incoming.NextAsync(cancellationToken); Assert.Equal(started.ActiveAttempt, firstAccepted.Synapse.Attempt); Assert.Equal(started.Revision, firstAccepted.Synapse.Revision); @@ -92,10 +74,7 @@ public async Task RetryableAttemptFailedSchedulesRetryThenNewAccept() Assert.True(failed.Synapse.Retryable); Assert.Equal(TaskFixtures.Retryable, failed.Synapse.Failure); - var waiting = await WaitForStateAsync( - task, - TaskState.Waiting, - cancellationToken); + var waiting = await WaitForStateAsync(task, TaskState.Waiting, cancellationToken); Assert.IsType(waiting.Blocker); Assert.Null(waiting.ActiveAttempt); Assert.Equal(TaskFixtures.Retryable, waiting.Failure); @@ -103,16 +82,12 @@ public async Task RetryableAttemptFailedSchedulesRetryThenNewAccept() await brain.Clock.AdvanceAsync(TaskFixtures.RetryDelay, cancellationToken); - var secondAccepted = await task.Incoming.NextAsync( - cancellationToken); + var secondAccepted = await task.Incoming.NextAsync(cancellationToken); Assert.NotEqual(started.ActiveAttempt, secondAccepted.Synapse.Attempt); Assert.Equal(started.Revision + 1, secondAccepted.Synapse.Revision); Assert.Equal(worker.Id, secondAccepted.Synapse.Worker); - var secondRunning = await WaitForStateAsync( - task, - TaskState.Running, - cancellationToken); + var secondRunning = await WaitForStateAsync(task, TaskState.Running, cancellationToken); Assert.Equal(secondAccepted.Synapse.Attempt, secondRunning.ActiveAttempt); Assert.Equal(secondAccepted.Synapse.Revision, secondRunning.Revision); Assert.Null(secondRunning.Blocker); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs index 7f950e7c6..e2aca3abe 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.Start.cs @@ -19,17 +19,9 @@ public async Task StartDispatchesAcceptAndAttemptAcceptedMovesTaskToRunning() Assert.NotNull(started.ActiveAttempt); var accepted = await task.Incoming.NextAsync(cancellationToken); - AssertAttempt( - accepted, - task.Id, - worker.Id, - started.ActiveAttempt, - started.Revision); + AssertAttempt(accepted, task.Id, worker.Id, started.ActiveAttempt, started.Revision); - var running = await WaitForStateAsync( - task, - TaskState.Running, - cancellationToken); + var running = await WaitForStateAsync(task, TaskState.Running, cancellationToken); Assert.Equal(started.ActiveAttempt, running.ActiveAttempt); Assert.Equal(started.Revision, running.Revision); @@ -60,8 +52,6 @@ await Assert.ThrowsAsync( new TestGoal("second-start"), worker.Id))); - Assert.Equal( - TaskState.Running, - (await task.Reference.Read()).State); + Assert.Equal(TaskState.Running, (await task.Reference.Read()).State); } } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs index 05b116c42..297d0455c 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TaskLifecycle.cs @@ -6,15 +6,8 @@ namespace DigitalBrain.Tasks.Tests; public sealed partial class TaskLifecycle(TasksFixture fixture) { - private static StartTask StartCommand( - Goal goal, - NeuronId worker, - TaskPolicy? policy = null) - => new( - CommandId.New(), - goal, - worker, - policy ?? TaskFixtures.SingleAttempt); + private static StartTask StartCommand(Goal goal, NeuronId worker, TaskPolicy? policy = null) + => new(CommandId.New(), goal, worker, policy ?? TaskFixtures.SingleAttempt); private static async Task<( TestNeuron Worker, @@ -27,8 +20,7 @@ private static StartTask StartCommand( { var worker = brain.Neuron($"{name}-worker"); var task = brain.Neuron($"{name}-task"); - var started = await task.Reference.Start( - StartCommand(goal, worker.Id, policy)); + var started = await task.Reference.Start(StartCommand(goal, worker.Id, policy)); return (worker, task, started); } @@ -56,9 +48,7 @@ private static async Task WaitForStateAsync( $"Task '{task.Id}' stayed in {final.State} instead of becoming {expected}."); } - private static async Task AcceptThenRunningAsync( - TestNeuron task, - CancellationToken cancellationToken) + private static async Task AcceptThenRunningAsync(TestNeuron task, CancellationToken cancellationToken) { _ = await task.Incoming.NextAsync(cancellationToken); return await WaitForStateAsync(task, TaskState.Running, cancellationToken); diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs index cb21d781d..0ffe1c8b3 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TasksHarnessModule.cs @@ -16,11 +16,7 @@ public async Task Accept(AttemptRequest request) await SendAsync( request.Task, - new AttemptAccepted( - request.Task, - request.Worker, - request.Attempt, - request.Revision)); + new AttemptAccepted(request.Task, request.Worker, request.Attempt, request.Revision)); switch (request.Goal) { @@ -75,12 +71,6 @@ public Task Cancel(AttemptCursor cursor) { ArgumentNullException.ThrowIfNull(cursor); - return SendAsync( - cursor.Task, - new AttemptCancelled( - cursor.Task, - cursor.Worker, - cursor.Attempt, - cursor.Revision)); + return SendAsync(cursor.Task, new AttemptCancelled(cursor.Task, cursor.Worker, cursor.Attempt, cursor.Revision)); } } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs index 09d2fb5d8..97716189d 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks.Tests/TestVocabulary.cs @@ -36,15 +36,9 @@ public static class TaskFixtures public static readonly TestResult StaleSuccess = new("stale-success"); public static readonly TestFailure Retryable = new("retryable"); - public static readonly TaskPolicy SingleAttempt = new( - MaximumAttempts: 1, - RetryDelay: TimeSpan.FromSeconds(1), - Deadline: null); + public static readonly TaskPolicy SingleAttempt = new(MaximumAttempts: 1, RetryDelay: TimeSpan.FromSeconds(1), Deadline: null); public static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30); - public static readonly TaskPolicy TwoAttempts = new( - MaximumAttempts: 2, - RetryDelay: RetryDelay, - Deadline: null); + public static readonly TaskPolicy TwoAttempts = new(MaximumAttempts: 2, RetryDelay: RetryDelay, Deadline: null); } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs index d4a95b564..c6fb28bb1 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Attempts.cs @@ -130,10 +130,7 @@ public async Task HandleAsync(AttemptFailed fact, CancellationToken cancellation { data.State = TaskState.Waiting; data.Blocker = new RetryScheduled(new BlockerId(Guid.NewGuid())); - await this.RegisterOrUpdateReminder( - RetryReminderName, - data.Policy.RetryDelay, - ReminderPeriod); + await this.RegisterOrUpdateReminder(RetryReminderName, data.Policy.RetryDelay, ReminderPeriod); Stage(data); return; } diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs index b571a37e1..1b8aec3e5 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Dispatch.cs @@ -4,10 +4,7 @@ namespace DigitalBrain.Tasks; internal sealed partial class TaskNeuron { private Task RegisterDispatchReminderAsync() - => this.RegisterOrUpdateReminder( - DispatchReminderName, - TimeSpan.FromSeconds(1), - ReminderPeriod); + => this.RegisterOrUpdateReminder(DispatchReminderName, TimeSpan.FromSeconds(1), ReminderPeriod); private async Task UnregisterReminderAsync(string reminderName) { diff --git a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs index ed3b61f76..2e06cbc22 100644 --- a/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs +++ b/src/modules/tasks/DigitalBrain.Modules.Tasks/TaskNeuron.Support.cs @@ -74,16 +74,12 @@ private static void Validate(StartTask command) if (command.Policy.MaximumAttempts <= 0) { - throw new ArgumentOutOfRangeException( - nameof(command), - "A task policy must allow at least one attempt."); + throw new ArgumentOutOfRangeException(nameof(command), "A task policy must allow at least one attempt."); } if (command.Policy.RetryDelay < TimeSpan.Zero) { - throw new ArgumentOutOfRangeException( - nameof(command), - "A task retry delay cannot be negative."); + throw new ArgumentOutOfRangeException(nameof(command), "A task retry delay cannot be negative."); } if (command.Worker == default) diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs index 69da5b345..5a4da34a7 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/ClientEntryPointCapability.cs @@ -13,24 +13,15 @@ public async Task ClientEntryPointStartDoesNotJournalCapabilityFacts() await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination) = TimeFixture.Pair(test); - var started = await TimeFixture.Start( - countdown, - destination, - TimeSpan.FromHours(1)); + var started = await TimeFixture.Start(countdown, destination, TimeSpan.FromHours(1)); Assert.Equal(CountdownStatus.Scheduled, started.Status); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Outgoing.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Incoming.ReadAsync( - cancellationToken: cancellationToken)); - Assert.Empty(await countdown.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Outgoing.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Incoming.ReadAsync(cancellationToken: cancellationToken)); + Assert.Empty(await countdown.Incoming.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs index 9117d18f5..176f13381 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.Validation.cs @@ -15,30 +15,19 @@ public async Task EmptyCommandIdIsRejected() var empty = default(CommandId); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - empty, - Hour, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(empty, Hour, destination.Id))); var started = await TimeFixture.Start(countdown, destination, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - empty, - started.Revision, - Hour))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(empty, started.Revision, Hour))); await Assert.ThrowsAsync( - () => countdown.Reference.Cancel(new CancelCountdown( - empty, - started.Revision))); + () => countdown.Reference.Cancel(new CancelCountdown(empty, started.Revision))); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown(CommandId.New(), started.Revision)); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - empty, - Hour))); + () => countdown.Reference.Restart(new RestartCountdown(empty, Hour))); Assert.Equal(cancelled, await countdown.Reference.Read()); } @@ -52,40 +41,23 @@ public async Task DurationMustBePositiveAndWithinRange() var (countdown, destination) = TimeFixture.Pair(test); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - TimeSpan.Zero, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), TimeSpan.Zero, destination.Id))); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - TimeSpan.FromTicks(-1), - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), TimeSpan.FromTicks(-1), destination.Id))); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - TimeSpan.MaxValue, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), TimeSpan.MaxValue, destination.Id))); var started = await TimeFixture.Start(countdown, destination, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - CommandId.New(), - started.Revision, - TimeSpan.Zero))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(CommandId.New(), started.Revision, TimeSpan.Zero))); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown(CommandId.New(), started.Revision)); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - TimeSpan.FromTicks(-1)))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TimeSpan.FromTicks(-1)))); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - TimeSpan.MaxValue))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TimeSpan.MaxValue))); Assert.Equal(cancelled, await countdown.Reference.Read()); } @@ -100,13 +72,8 @@ public async Task DestinationMustBelongToTheCountdownOwner() var foreign = test.Owner("foreign").Neuron(TimeFixture.Destination); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - Hour, - foreign.Id))); - - Assert.Equal( - CountdownStatus.Unscheduled, - (await countdown.Reference.Read()).Status); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), Hour, foreign.Id))); + + Assert.Equal(CountdownStatus.Unscheduled, (await countdown.Reference.Read()).Status); } } diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs index a524e8f92..1b4bc711c 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownLifecycle.cs @@ -24,10 +24,7 @@ public async Task StartIsIdempotentAndAllowedOnlyFromUnscheduled() Assert.Equal(0, empty.Revision); Assert.Null(empty.Destination); - var command = new StartCountdown( - CommandId.New(), - Hour, - destination.Id); + var command = new StartCountdown(CommandId.New(), Hour, destination.Id); var started = await countdown.Reference.Start(command); var repeated = await countdown.Reference.Start(command); @@ -41,10 +38,7 @@ public async Task StartIsIdempotentAndAllowedOnlyFromUnscheduled() Assert.Equal(Hour, started.Duration); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - Hour, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), Hour, destination.Id))); } [Fact(DisplayName = @@ -77,12 +71,10 @@ await Assert.ThrowsAsync( Assert.Equal(test.Clock.UtcNow + Hour, rescheduled.DueAt); await test.Clock.AdvanceAsync(HalfHour, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); await test.Clock.AdvanceAsync(HalfHour, cancellationToken); - var elapsed = await destination.Incoming.NextAsync( - cancellationToken); + var elapsed = await destination.Incoming.NextAsync(cancellationToken); Assert.Equal(rescheduled.Generation, elapsed.Synapse.Generation); Assert.Equal(rescheduled.Revision, elapsed.Synapse.Revision); @@ -97,13 +89,9 @@ public async Task CancelUsesTheExactRevisionAndIsTerminal() var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Cancel(new CancelCountdown( - CommandId.New(), - ExpectedRevision: started.Revision + 1))); + () => countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), ExpectedRevision: started.Revision + 1))); - var command = new CancelCountdown( - CommandId.New(), - started.Revision); + var command = new CancelCountdown(CommandId.New(), started.Revision); var cancelled = await countdown.Reference.Cancel(command); var repeated = await countdown.Reference.Cancel(command); @@ -113,23 +101,14 @@ await Assert.ThrowsAsync( Assert.Equal(started.Revision, cancelled.Revision); await Assert.ThrowsAsync( - () => countdown.Reference.Cancel(new CancelCountdown( - CommandId.New(), - cancelled.Revision))); + () => countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), cancelled.Revision))); await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - CommandId.New(), - cancelled.Revision, - Hour))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(CommandId.New(), cancelled.Revision, Hour))); await Assert.ThrowsAsync( - () => countdown.Reference.Start(new StartCountdown( - CommandId.New(), - Hour, - destination.Id))); + () => countdown.Reference.Start(new StartCountdown(CommandId.New(), Hour, destination.Id))); await test.Clock.AdvanceAsync(TwoHours, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = @@ -141,16 +120,10 @@ public async Task RestartRetainsDestinationAndStartsANewGeneration() var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - Hour))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), Hour))); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown(CommandId.New(), started.Revision)); - var restarted = await countdown.Reference.Restart( - new RestartCountdown( - CommandId.New(), - TwoHours)); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); + var restarted = await countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TwoHours)); Assert.Equal(CountdownStatus.Scheduled, restarted.Status); Assert.Equal(cancelled.Generation + 1, restarted.Generation); @@ -169,13 +142,9 @@ public async Task RestartIsAllowedAfterElapsed() var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); await test.Clock.AdvanceAsync(Hour, cancellationToken); - _ = await destination.Incoming.NextAsync( - cancellationToken); + _ = await destination.Incoming.NextAsync(cancellationToken); - var restarted = await countdown.Reference.Restart( - new RestartCountdown( - CommandId.New(), - TwoHours)); + var restarted = await countdown.Reference.Restart(new RestartCountdown(CommandId.New(), TwoHours)); Assert.Equal(CountdownStatus.Scheduled, restarted.Status); Assert.Equal(started.Generation + 1, restarted.Generation); @@ -190,15 +159,11 @@ public async Task CountdownEmitsExactlyOnceAtItsDueInstant() await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination, started) = await TimeFixture.Schedule(test, Hour); - await test.Clock.AdvanceAsync( - TimeSpan.FromMinutes(59), - cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + await test.Clock.AdvanceAsync(TimeSpan.FromMinutes(59), cancellationToken); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - var elapsed = await destination.Incoming.NextAsync( - cancellationToken); + var elapsed = await destination.Incoming.NextAsync(cancellationToken); Assert.Equal(countdown.Id, elapsed.Synapse.Countdown); Assert.Equal(started.Generation, elapsed.Synapse.Generation); @@ -208,13 +173,10 @@ await test.Clock.AdvanceAsync( Assert.Equal(started.DueAt, elapsed.Synapse.DueAt); Assert.Equal(test.Clock.UtcNow, elapsed.Synapse.ObservedAt); Assert.Equal(CountdownResolution.OnTime, elapsed.Synapse.Resolution); - Assert.Equal( - CountdownStatus.Elapsed, - (await countdown.Reference.Read()).Status); + Assert.Equal(CountdownStatus.Elapsed, (await countdown.Reference.Read()).Status); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Single(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Single(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = "Read returns the committed snapshot after a hosting silo restart")] diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs index ba1a1671d..6ed4ab15f 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/CountdownRecovery.cs @@ -99,8 +99,7 @@ public async Task CommittedElapsedSurvivesAnotherHostRestartWithoutDuplication() await countdown.RestartHostAsync(cancellationToken); await test.Clock.AdvanceAsync(TimeSpan.FromMinutes(2), cancellationToken); - var committed = await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken); + var committed = await destination.Incoming.ReadAsync(cancellationToken: cancellationToken); Assert.Single(committed); Assert.Equal(first.SynapseId, committed[0].SynapseId); @@ -113,8 +112,7 @@ public async Task FailedStartCommitLeavesUnscheduledWithoutElapsed() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination) = TimeFixture.Pair(test); - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.StartStateCommitFailure); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.StartStateCommitFailure); var failure = await Assert.ThrowsAsync( () => TimeFixture.Start(countdown, destination, OneHour)); @@ -126,8 +124,7 @@ public async Task FailedStartCommitLeavesUnscheduledWithoutElapsed() await test.Clock.AdvanceAsync(OneHour, cancellationToken); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } [Fact(DisplayName = @@ -137,20 +134,15 @@ public async Task FailedRescheduleCommitKeepsCommittedSchedule() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination, started) = await TimeFixture.Schedule(test, TwoHours); - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.RescheduleStateCommitFailure); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.RescheduleStateCommitFailure); var failure = await Assert.ThrowsAsync( - () => countdown.Reference.Reschedule(new RescheduleCountdown( - CommandId.New(), - started.Revision, - OneHour))); + () => countdown.Reference.Reschedule(new RescheduleCountdown(CommandId.New(), started.Revision, OneHour))); Assert.Equal(TimeFixture.RescheduleStateCommitFailure, failure.Message); Assert.Equal(started, await countdown.Reference.Read()); await test.Clock.AdvanceAsync(OneHour, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); await test.Clock.AdvanceAsync(OneHour, cancellationToken); await AssertElapsed(destination, started, CountdownResolution.OnTime, cancellationToken); @@ -164,25 +156,18 @@ public async Task FailedRestartCommitKeepsCancelledWithoutElapsed() var cancellationToken = TestContext.Current.CancellationToken; await using var test = await fixture.CreateBrainAsync(cancellationToken); var (countdown, destination, started) = await TimeFixture.Schedule(test, TwoHours); - var cancelled = await countdown.Reference.Cancel( - new CancelCountdown( - CommandId.New(), - started.Revision)); - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.RestartStateCommitFailure); + var cancelled = await countdown.Reference.Cancel(new CancelCountdown(CommandId.New(), started.Revision)); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.RestartStateCommitFailure); var failure = await Assert.ThrowsAsync( - () => countdown.Reference.Restart(new RestartCountdown( - CommandId.New(), - OneHour))); + () => countdown.Reference.Restart(new RestartCountdown(CommandId.New(), OneHour))); Assert.Equal(TimeFixture.RestartStateCommitFailure, failure.Message); Assert.Equal(cancelled, await countdown.Reference.Read()); await test.Clock.AdvanceAsync(OneHour, cancellationToken); await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); Assert.Equal(cancelled, await countdown.Reference.Read()); } @@ -192,16 +177,14 @@ private static async Task AssertElapsedCommitFails( TestNeuron destination, CancellationToken cancellationToken) { - await using var fault = countdown.FailNextJournalCommit( - TimeFixture.OccurrenceCommitFailure); + await using var fault = countdown.FailNextJournalCommit(TimeFixture.OccurrenceCommitFailure); var failure = await Assert.ThrowsAsync( () => test.Clock.AdvanceAsync(OneHour, cancellationToken)); Assert.Equal( TimeFixture.OccurrenceCommitFailure, failure.InnerException?.Message); - Assert.Empty(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Empty(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } private static async Task> AssertElapsed( @@ -210,8 +193,7 @@ private static async Task> AssertElapsed( CountdownResolution resolution, CancellationToken cancellationToken) { - var elapsed = await destination.Incoming.NextAsync( - cancellationToken); + var elapsed = await destination.Incoming.NextAsync(cancellationToken); Assert.Equal(started.Generation, elapsed.Synapse.Generation); Assert.Equal(started.Revision, elapsed.Synapse.Revision); Assert.Equal(resolution, elapsed.Synapse.Resolution); @@ -224,7 +206,6 @@ private static async Task AssertNoFurtherElapsed( CancellationToken cancellationToken) { await test.Clock.AdvanceAsync(OneMinute, cancellationToken); - Assert.Single(await destination.Incoming.ReadAsync( - cancellationToken: cancellationToken)); + Assert.Single(await destination.Incoming.ReadAsync(cancellationToken: cancellationToken)); } } diff --git a/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs b/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs index accb405b3..59bfd7971 100644 --- a/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs +++ b/src/modules/time/DigitalBrain.Modules.Time.Tests/TimeFixture.cs @@ -27,10 +27,7 @@ public static Task Start( { ArgumentNullException.ThrowIfNull(countdown); ArgumentNullException.ThrowIfNull(destination); - return countdown.Reference.Start(new StartCountdown( - CommandId.New(), - duration, - destination.Id)); + return countdown.Reference.Start(new StartCountdown(CommandId.New(), duration, destination.Id)); } public static async Task<( diff --git a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs index 365388780..4f863ffa9 100644 --- a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs +++ b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.Recovery.cs @@ -26,9 +26,7 @@ private async Task ElapseIfDue(long generation, long revision) var observedAt = TimeProvider.GetUtcNow(); if (observedAt < data.DueAt) { - await RegisterReminderAsync( - reminderName, - data.DueAt - observedAt); + await RegisterReminderAsync(reminderName, data.DueAt - observedAt); return; } @@ -72,13 +70,8 @@ await SendAsync( await RetireReminderAsync(reminderName); } - private Task RegisterReminderAsync( - string reminderName, - TimeSpan dueTime) - => this.RegisterOrUpdateReminder( - reminderName, - dueTime, - ReminderPeriod); + private Task RegisterReminderAsync(string reminderName, TimeSpan dueTime) + => this.RegisterOrUpdateReminder(reminderName, dueTime, ReminderPeriod); private async Task RetireReminderAsync(string? reminderName) { @@ -90,21 +83,14 @@ private async Task RetireReminderAsync(string? reminderName) } private static string ReminderName(long generation, long revision) - => string.Create( - CultureInfo.InvariantCulture, - $"{ReminderPrefix}{generation}.{revision}"); + => string.Create(CultureInfo.InvariantCulture, $"{ReminderPrefix}{generation}.{revision}"); - private static bool TryParseReminderName( - string reminderName, - out long generation, - out long revision) + private static bool TryParseReminderName(string reminderName, out long generation, out long revision) { generation = 0; revision = 0; - if (!reminderName.StartsWith( - ReminderPrefix, - StringComparison.Ordinal)) + if (!reminderName.StartsWith(ReminderPrefix, StringComparison.Ordinal)) { return false; } @@ -114,16 +100,8 @@ private static bool TryParseReminderName( return separator > 0 && separator == suffix.LastIndexOf('.', StringComparison.Ordinal) - && long.TryParse( - suffix.AsSpan(0, separator), - NumberStyles.None, - CultureInfo.InvariantCulture, - out generation) - && long.TryParse( - suffix.AsSpan(separator + 1), - NumberStyles.None, - CultureInfo.InvariantCulture, - out revision) + && long.TryParse(suffix.AsSpan(0, separator), NumberStyles.None, CultureInfo.InvariantCulture, out generation) + && long.TryParse(suffix.AsSpan(separator + 1), NumberStyles.None, CultureInfo.InvariantCulture, out revision) && generation > 0 && revision > 0; } diff --git a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs index ea87a54e3..c5f9a9c3e 100644 --- a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs +++ b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.State.cs @@ -17,9 +17,7 @@ private CountdownState Load() private void Stage(CountdownState data) => _state.Value = _states.SerializeToArray(data); - private async Task SaveAsync( - CountdownState data, - byte[] rollbackState) + private async Task SaveAsync(CountdownState data, byte[] rollbackState) { Stage(data); @@ -42,10 +40,7 @@ private byte[] SerializedState() private void RestoreState(byte[] serialized) => _state.Value = serialized; - private static bool TryReceipt( - CountdownState? data, - CommandId commandId, - out CountdownSnapshot snapshot) + private static bool TryReceipt(CountdownState? data, CommandId commandId, out CountdownSnapshot snapshot) { if (data is not null && data.Receipts.TryGetValue(commandId, out var received)) @@ -58,10 +53,7 @@ private static bool TryReceipt( return false; } - private static void Remember( - CountdownState data, - CommandId commandId, - CountdownSnapshot snapshot) + private static void Remember(CountdownState data, CommandId commandId, CountdownSnapshot snapshot) { while (data.Receipts.Count >= MaximumReceipts) { @@ -116,15 +108,11 @@ private static void Validate(CommandId commandId) { if (commandId.Value == Guid.Empty) { - throw new ArgumentException( - "A command id is required.", - nameof(commandId)); + throw new ArgumentException("A command id is required.", nameof(commandId)); } } - private static DateTimeOffset DueAt( - DateTimeOffset scheduledAt, - TimeSpan duration) + private static DateTimeOffset DueAt(DateTimeOffset scheduledAt, TimeSpan duration) { if (duration <= TimeSpan.Zero) { @@ -155,9 +143,7 @@ private void RequireScheduled(CountdownState data) } } - private void RequireRevision( - CountdownState data, - long expectedRevision) + private void RequireRevision(CountdownState data, long expectedRevision) { if (data.Revision != expectedRevision) { diff --git a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs index ef63c91c1..4ba4d93b8 100644 --- a/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs +++ b/src/modules/time/DigitalBrain.Modules.Time/CountdownNeuron.cs @@ -196,14 +196,9 @@ public async Task Restart(RestartCountdown command) return snapshot; } - async Task IRemindable.ReceiveReminder( - string reminderName, - TickStatus status) + async Task IRemindable.ReceiveReminder(string reminderName, TickStatus status) { - if (!TryParseReminderName( - reminderName, - out var generation, - out var revision)) + if (!TryParseReminderName(reminderName, out var generation, out var revision)) { throw new InvalidOperationException( $"Countdown neuron '{Id}' does not own reminder '{reminderName}'."); diff --git a/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs b/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs index aa42f834a..58c538ce0 100644 --- a/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs +++ b/tests/fixtures/DigitalBrain.AccountEnrichment/AccountEnrichment.cs @@ -23,14 +23,11 @@ internal sealed class AccountEnrichment : public AccountEnrichment() { - _requests = ServiceProvider.GetRequiredKeyedService>( - RequestsName); + _requests = ServiceProvider.GetRequiredKeyedService>(RequestsName); _states = ServiceProvider.GetRequiredService>(); } - public async Task HandleAsync( - EnrichAccountFromEmail synapse, - CancellationToken cancellationToken) + public async Task HandleAsync(EnrichAccountFromEmail synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); Validate(synapse); @@ -41,10 +38,8 @@ public async Task HandleAsync( return; } - var gmail = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, synapse.GmailAccount).ToGrainId()); - var salesforce = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, "salesforce").ToGrainId()); + var gmail = GrainFactory.GetGrain(NeuronId.For(Id.Owner, synapse.GmailAccount).ToGrainId()); + var salesforce = GrainFactory.GetGrain(NeuronId.For(Id.Owner, "salesforce").ToGrainId()); var message = await gmail.ReadMessage(synapse.MessageId, cancellationToken); var description = @@ -74,25 +69,19 @@ await EmitAsync(new AccountEnrichmentProposed( mutation.Fingerprint)); } - public Task HandleAsync( - SalesforceMutationApproval synapse, - CancellationToken cancellationToken) + public Task HandleAsync(SalesforceMutationApproval synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); cancellationToken.ThrowIfCancellationRequested(); var request = Load(synapse.CommandId) - ?? throw new InvalidOperationException( - $"Account enrichment '{synapse.CommandId}' has no durable request."); + ?? throw new InvalidOperationException($"Account enrichment '{synapse.CommandId}' has no durable request."); if (request.Completed) { return Task.CompletedTask; } - if (!string.Equals( - request.MutationFingerprint, - synapse.Fingerprint, - StringComparison.Ordinal)) + if (!string.Equals(request.MutationFingerprint, synapse.Fingerprint, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Salesforce approval '{synapse.ApprovalId}' does not match the enrichment proposal."); @@ -101,37 +90,27 @@ public Task HandleAsync( return SendAsync(Id, new ExecuteApprovedAccountEnrichment(synapse)); } - public async Task HandleAsync( - ExecuteApprovedAccountEnrichment synapse, - CancellationToken cancellationToken) + public async Task HandleAsync(ExecuteApprovedAccountEnrichment synapse, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(synapse); var approval = synapse.Approval; var request = Load(approval.CommandId) - ?? throw new InvalidOperationException( - $"Account enrichment '{approval.CommandId}' has no durable request."); + ?? throw new InvalidOperationException($"Account enrichment '{approval.CommandId}' has no durable request."); if (request.Completed) { return; } - if (!string.Equals( - request.MutationFingerprint, - approval.Fingerprint, - StringComparison.Ordinal)) + if (!string.Equals(request.MutationFingerprint, approval.Fingerprint, StringComparison.Ordinal)) { throw new InvalidOperationException( $"Salesforce approval '{approval.ApprovalId}' does not match the enrichment proposal."); } var evidence = await ApprovalEvidenceAsync(approval); - var salesforce = GrainFactory.GetGrain( - NeuronId.For(Id.Owner, "salesforce").ToGrainId()); - var mutation = await salesforce.ApproveAccountDescription( - approval, - evidence, - cancellationToken); + var salesforce = GrainFactory.GetGrain(NeuronId.For(Id.Owner, "salesforce").ToGrainId()); + var mutation = await salesforce.ApproveAccountDescription(approval, evidence, cancellationToken); if (mutation.State is not SalesforceMutationState.Completed) { @@ -139,9 +118,7 @@ public async Task HandleAsync( $"Salesforce could not prove completion of Account '{mutation.AccountId}' enrichment."); } - Stage( - approval.CommandId, - request with { Completed = true }); + Stage(approval.CommandId, request with { Completed = true }); await EmitAsync(new AccountEnriched( mutation.CommandId, request.MessageId, @@ -164,8 +141,7 @@ Task INeuron.Deliver(SynapseDelivery delivery) return base.Deliver(delivery); } - private async Task ApprovalEvidenceAsync( - SalesforceMutationApproval approval) + private async Task ApprovalEvidenceAsync(SalesforceMutationApproval approval) { var incoming = await ReadJournal(JournalKind.Incoming, afterSequence: 0); return incoming.Delta.FirstOrDefault(delivery => @@ -214,9 +190,7 @@ private static void Validate(EnrichAccountFromEmail request) { if (request.CommandId.Value == Guid.Empty) { - throw new ArgumentException( - "An account-enrichment command id cannot be empty.", - nameof(request)); + throw new ArgumentException("An account-enrichment command id cannot be empty.", nameof(request)); } ArgumentException.ThrowIfNullOrWhiteSpace(request.MessageId); diff --git a/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs b/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs index 019fea2bb..bf3c16c69 100644 --- a/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs +++ b/tests/fixtures/DigitalBrain.Compositions/AccountEnrichmentSurface.cs @@ -9,10 +9,7 @@ public sealed class AccountEnrichmentSurface public const string SceneKey = "enrichment"; public const string SceneTitle = "Account enrichment"; - public async Task RunAsync( - IDigitalBrain brain, - string shellName, - CancellationToken cancellationToken) + public async Task RunAsync(IDigitalBrain brain, string shellName, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(brain); ArgumentException.ThrowIfNullOrWhiteSpace(shellName); diff --git a/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs b/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs index 836127411..46835387d 100644 --- a/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs +++ b/tests/fixtures/DigitalBrain.Compositions/CountdownSurface.cs @@ -27,9 +27,6 @@ public async Task RunAsync( var countdown = brain.Get(countdownName); var destination = NeuronId.For(brain.Owner, SceneKey); - return await countdown.Start(new StartCountdown( - CommandId.New(), - duration, - destination)); + return await countdown.Start(new StartCountdown(CommandId.New(), duration, destination)); } } diff --git a/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs b/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs index 1794133c8..1b9b9d17f 100644 --- a/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs +++ b/tests/fixtures/DigitalBrain.Compositions/OpenHome.cs @@ -9,10 +9,7 @@ public sealed class OpenHome public const string SceneKey = "home"; public const string SceneTitle = "Home"; - public async Task RunAsync( - IDigitalBrain brain, - string shellName, - CancellationToken cancellationToken) + public async Task RunAsync(IDigitalBrain brain, string shellName, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(brain); ArgumentException.ThrowIfNullOrWhiteSpace(shellName); diff --git a/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs b/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs index 2a55a673c..c1f0a977c 100644 --- a/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs +++ b/tests/fixtures/DigitalBrain.Quickstart.Contracts/GreetingSynapses.cs @@ -4,10 +4,8 @@ namespace DigitalBrain.Quickstart; [GenerateSerializer] [Alias("quickstart.say-hello")] -public sealed record SayHello( - [property: Id(0)] string Name) : Synapse; +public sealed record SayHello([property: Id(0)] string Name) : Synapse; [GenerateSerializer] [Alias("quickstart.greeted")] -public sealed record Greeted( - [property: Id(0)] string Message) : Synapse; +public sealed record Greeted([property: Id(0)] string Message) : Synapse; diff --git a/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs b/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs index 363ea9a49..621069d06 100644 --- a/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs +++ b/tests/fixtures/DigitalBrain.Quickstart.Tests/GreetingBehavior.cs @@ -20,9 +20,7 @@ public async Task GreetingIsDurableAcrossItsHostingSiloRestart() await greeter.RestartHostAsync(cancellationToken); - var committed = await greeter.Outgoing.ReadAsync( - afterSequence: 0, - cancellationToken); + var committed = await greeter.Outgoing.ReadAsync(afterSequence: 0, cancellationToken); Assert.Single(committed); Assert.Equal(first.SynapseId, committed[0].SynapseId); } diff --git a/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs b/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs index 5d1ba283a..4c1963327 100644 --- a/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs +++ b/tests/fixtures/DigitalBrain.Quickstart/Greeter.cs @@ -9,8 +9,6 @@ internal sealed class Greeter : IHandle, IEmit { - public Task HandleAsync( - SayHello request, - CancellationToken cancellationToken) + public Task HandleAsync(SayHello request, CancellationToken cancellationToken) => EmitAsync(new Greeted($"Hello, {request.Name}.")); }