diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 5ba47dc..71515a6 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -125,6 +125,25 @@ class SystemActions { return _runFai(['daemon', 'disable', '--channel', channel]); } + /// Run `fai reset --yes`. Wipes operator state with the same + /// safety net as the CLI: stops every daemon (including off- + /// channel orphans), atomically backs `~/.fai/` up, recreates + /// it with `bin/`, `channels/`, `config.yaml`, `current-channel`, + /// and `registry-token` preserved. The daemon restarts on the + /// active channel before the call returns. + /// + /// Studio's gRPC channel goes down for ~1s while the daemon + /// restarts — caller should follow up with a reconnect. + static Future<({bool ok, String stdout, String stderr})> faiReset({ + bool keepModules = false, + bool keepData = false, + }) async { + final args = ['reset', '--yes']; + if (keepModules) args.add('--keep-modules'); + if (keepData) args.add('--keep-data'); + return _runFai(args); + } + static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 14897c3..5923bb9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -449,6 +449,17 @@ "@registryTokenSaveFailedToast": { "placeholders": { "error": { "type": "String" } } }, + "maintenanceHeader": "HUB-WARTUNG", + "maintenanceResetBlurb": "Operator-Zustand zurücksetzen und mit einem sauberen Hub neu starten. Stoppt jeden laufenden Daemon, sichert ~/.fai/ atomar in ein Backup, legt es dann neu an — Binary, Channel-Pointer, MCP-/n8n-/System-AI-Konfiguration und Registry-Token bleiben erhalten. Wiederherstellen durch Zurückverschieben des Backup-Ordners.", + "maintenanceKeepModulesTitle": "Installierte Module behalten", + "maintenanceKeepModulesSubtitle": "Standardverhalten löscht ~/.fai/modules/, damit eine frische Installation den Modul-Bezugspfad end-to-end verifizieren kann.", + "maintenanceKeepDataTitle": "Protokoll + gespeicherte Flows behalten", + "maintenanceKeepDataSubtitle": "Standardverhalten löscht ~/.fai/data/, damit die Audit-Kette mit einer neuen Genesis startet und die mitgelieferten Sample-Flows beim ersten Daemon-Start neu importiert werden.", + "maintenanceResetButton": "Hub zurücksetzen", + "maintenanceResetInProgress": "Zurücksetzen…", + "maintenanceResetConfirmTitle": "Hub zurücksetzen?", + "maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.fai/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?", + "maintenanceResetConfirmButton": "Zurücksetzen", "welcomeChecklistAllSetTitle": "Du bist eingerichtet.", "welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:", "welcomeChecklistNextAuditTitle": "Audit-Log lesen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0b099a4..9c566b7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -450,6 +450,17 @@ "@registryTokenSaveFailedToast": { "placeholders": { "error": { "type": "String" } } }, + "maintenanceHeader": "HUB MAINTENANCE", + "maintenanceResetBlurb": "Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.", + "maintenanceKeepModulesTitle": "Keep installed modules", + "maintenanceKeepModulesSubtitle": "Default behaviour wipes ~/.fai/modules/ so a fresh install can verify the module-fetch path end-to-end.", + "maintenanceKeepDataTitle": "Keep audit log + saved flows", + "maintenanceKeepDataSubtitle": "Default behaviour wipes ~/.fai/data/ so the audit chain restarts from a fresh genesis and the bundled sample flows re-import on first daemon start.", + "maintenanceResetButton": "Reset hub state", + "maintenanceResetInProgress": "Resetting…", + "maintenanceResetConfirmTitle": "Reset hub state?", + "maintenanceResetConfirmBody": "This stops every daemon, moves ~/.fai/ to a timestamped backup, and starts fresh. The backup is fully restorable. Continue?", + "maintenanceResetConfirmButton": "Reset", "welcomeChecklistAllSetTitle": "You're set up.", "welcomeChecklistAllSetBody": "Three threads to pull on next:", "welcomeChecklistNextAuditTitle": "Read the audit log", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7ab7671..d22a2a0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2042,6 +2042,72 @@ abstract class AppLocalizations { /// **'Could not save: {error}'** String registryTokenSaveFailedToast(String error); + /// No description provided for @maintenanceHeader. + /// + /// In en, this message translates to: + /// **'HUB MAINTENANCE'** + String get maintenanceHeader; + + /// No description provided for @maintenanceResetBlurb. + /// + /// In en, this message translates to: + /// **'Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.'** + String get maintenanceResetBlurb; + + /// No description provided for @maintenanceKeepModulesTitle. + /// + /// In en, this message translates to: + /// **'Keep installed modules'** + String get maintenanceKeepModulesTitle; + + /// No description provided for @maintenanceKeepModulesSubtitle. + /// + /// In en, this message translates to: + /// **'Default behaviour wipes ~/.fai/modules/ so a fresh install can verify the module-fetch path end-to-end.'** + String get maintenanceKeepModulesSubtitle; + + /// No description provided for @maintenanceKeepDataTitle. + /// + /// In en, this message translates to: + /// **'Keep audit log + saved flows'** + String get maintenanceKeepDataTitle; + + /// No description provided for @maintenanceKeepDataSubtitle. + /// + /// In en, this message translates to: + /// **'Default behaviour wipes ~/.fai/data/ so the audit chain restarts from a fresh genesis and the bundled sample flows re-import on first daemon start.'** + String get maintenanceKeepDataSubtitle; + + /// No description provided for @maintenanceResetButton. + /// + /// In en, this message translates to: + /// **'Reset hub state'** + String get maintenanceResetButton; + + /// No description provided for @maintenanceResetInProgress. + /// + /// In en, this message translates to: + /// **'Resetting…'** + String get maintenanceResetInProgress; + + /// No description provided for @maintenanceResetConfirmTitle. + /// + /// In en, this message translates to: + /// **'Reset hub state?'** + String get maintenanceResetConfirmTitle; + + /// No description provided for @maintenanceResetConfirmBody. + /// + /// In en, this message translates to: + /// **'This stops every daemon, moves ~/.fai/ to a timestamped backup, and starts fresh. The backup is fully restorable. Continue?'** + String get maintenanceResetConfirmBody; + + /// No description provided for @maintenanceResetConfirmButton. + /// + /// In en, this message translates to: + /// **'Reset'** + String get maintenanceResetConfirmButton; + /// No description provided for @welcomeChecklistAllSetTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7d4f4a9..569cfce 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1159,6 +1159,44 @@ class AppLocalizationsDe extends AppLocalizations { return 'Speichern fehlgeschlagen: $error'; } + @override + String get maintenanceHeader => 'HUB-WARTUNG'; + + @override + String get maintenanceResetBlurb => + 'Operator-Zustand zurücksetzen und mit einem sauberen Hub neu starten. Stoppt jeden laufenden Daemon, sichert ~/.fai/ atomar in ein Backup, legt es dann neu an — Binary, Channel-Pointer, MCP-/n8n-/System-AI-Konfiguration und Registry-Token bleiben erhalten. Wiederherstellen durch Zurückverschieben des Backup-Ordners.'; + + @override + String get maintenanceKeepModulesTitle => 'Installierte Module behalten'; + + @override + String get maintenanceKeepModulesSubtitle => + 'Standardverhalten löscht ~/.fai/modules/, damit eine frische Installation den Modul-Bezugspfad end-to-end verifizieren kann.'; + + @override + String get maintenanceKeepDataTitle => + 'Protokoll + gespeicherte Flows behalten'; + + @override + String get maintenanceKeepDataSubtitle => + 'Standardverhalten löscht ~/.fai/data/, damit die Audit-Kette mit einer neuen Genesis startet und die mitgelieferten Sample-Flows beim ersten Daemon-Start neu importiert werden.'; + + @override + String get maintenanceResetButton => 'Hub zurücksetzen'; + + @override + String get maintenanceResetInProgress => 'Zurücksetzen…'; + + @override + String get maintenanceResetConfirmTitle => 'Hub zurücksetzen?'; + + @override + String get maintenanceResetConfirmBody => + 'Stoppt jeden Daemon, verschiebt ~/.fai/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?'; + + @override + String get maintenanceResetConfirmButton => 'Zurücksetzen'; + @override String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1a17e23..ab8a19d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1174,6 +1174,43 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not save: $error'; } + @override + String get maintenanceHeader => 'HUB MAINTENANCE'; + + @override + String get maintenanceResetBlurb => + 'Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.'; + + @override + String get maintenanceKeepModulesTitle => 'Keep installed modules'; + + @override + String get maintenanceKeepModulesSubtitle => + 'Default behaviour wipes ~/.fai/modules/ so a fresh install can verify the module-fetch path end-to-end.'; + + @override + String get maintenanceKeepDataTitle => 'Keep audit log + saved flows'; + + @override + String get maintenanceKeepDataSubtitle => + 'Default behaviour wipes ~/.fai/data/ so the audit chain restarts from a fresh genesis and the bundled sample flows re-import on first daemon start.'; + + @override + String get maintenanceResetButton => 'Reset hub state'; + + @override + String get maintenanceResetInProgress => 'Resetting…'; + + @override + String get maintenanceResetConfirmTitle => 'Reset hub state?'; + + @override + String get maintenanceResetConfirmBody => + 'This stops every daemon, moves ~/.fai/ to a timestamped backup, and starts fresh. The backup is fully restorable. Continue?'; + + @override + String get maintenanceResetConfirmButton => 'Reset'; + @override String get welcomeChecklistAllSetTitle => 'You\'re set up.'; diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index ac00dd8..439453b 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -509,6 +509,24 @@ class _FaiSettingsDialogState extends State { onSave: _saveRegistryToken, onClear: _clearRegistryToken, ), + const SizedBox(height: FaiSpace.lg), + _MaintenancePanel( + onResetDone: () async { + // Daemon just restarted under the same channel + + // port. Bouncing the gRPC connection picks up the + // fresh state without the operator having to + // close+reopen Settings. + await HubService.instance.reconnect( + HubService.instance.currentEndpoint, + ); + if (!mounted) return; + await _loadChannels(); + await _loadAiStatus(); + await _loadMcpClients(); + await _loadN8nEndpoints(); + await _loadRegistryToken(); + }, + ), ], ), ), @@ -1437,6 +1455,163 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { /// metadata; nothing is auto-spawned. Trust model: /// referencing public projects with their canonical install /// command is the same legal / security shape as a package +/// Hub maintenance panel — currently one action: reset operator +/// state via `fai reset --yes`. Lives at the bottom of Settings +/// so the everyday config controls aren't crowded by a destructive +/// button. The reset itself is gated by an interactive confirm +/// dialog before the CLI is invoked. +class _MaintenancePanel extends StatefulWidget { + /// Called after a successful reset so the parent Settings dialog + /// can bounce its gRPC channel and reload its panels. + final Future Function() onResetDone; + + const _MaintenancePanel({required this.onResetDone}); + + @override + State<_MaintenancePanel> createState() => _MaintenancePanelState(); +} + +class _MaintenancePanelState extends State<_MaintenancePanel> { + bool _keepModules = false; + bool _keepData = false; + bool _resetting = false; + String? _resultText; + bool _resultOk = false; + + Future _confirmAndReset() async { + final l = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.maintenanceResetConfirmTitle), + content: Text(l.maintenanceResetConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l.buttonCancel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + onPressed: () => Navigator.pop(ctx, true), + child: Text(l.maintenanceResetConfirmButton), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() { + _resetting = true; + _resultText = null; + }); + final r = await SystemActions.faiReset( + keepModules: _keepModules, + keepData: _keepData, + ); + if (!mounted) return; + setState(() { + _resetting = false; + _resultOk = r.ok; + _resultText = r.ok + ? r.stdout.trim() + : (r.stderr.isEmpty ? r.stdout : r.stderr).trim(); + }); + if (r.ok) await widget.onResetDone(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.maintenanceHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 2), + Text( + l.maintenanceResetBlurb, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.sm), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: _keepModules, + onChanged: _resetting + ? null + : (v) => setState(() => _keepModules = v ?? false), + title: Text(l.maintenanceKeepModulesTitle), + subtitle: Text( + l.maintenanceKeepModulesSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: _keepData, + onChanged: _resetting + ? null + : (v) => setState(() => _keepData = v ?? false), + title: Text(l.maintenanceKeepDataTitle), + subtitle: Text( + l.maintenanceKeepDataSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: FaiSpace.sm), + Row( + children: [ + FilledButton.icon( + icon: _resetting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.restart_alt, size: 16), + label: Text( + _resetting + ? l.maintenanceResetInProgress + : l.maintenanceResetButton, + ), + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + onPressed: _resetting ? null : _confirmAndReset, + ), + ], + ), + if (_resultText != null) ...[ + const SizedBox(height: FaiSpace.sm), + FaiErrorBox( + text: _resultText!, + isError: !_resultOk, + maxHeight: 240, + ), + ], + ], + ); + } +} + /// Credentials panel for the operator's registry auth token. /// The hub reads the same token via `~/.fai/registry-token` /// (or the `FAI_REGISTRY_TOKEN` env var, which still wins) diff --git a/pubspec.yaml b/pubspec.yaml index f5ca94f..bebe178 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.44.0 +version: 0.45.0 environment: sdk: ^3.11.0-200.1.beta