import 'dart:async'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/friendly_error.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; /// Detached-runs monitor (T3 parity): the invocations submitted with /// `detach: true`, with a cancel button while they are still running. /// Workspace-scoped like Audit and Approvals. Detached invocations are /// opt-in (`detached.enabled`), so an empty list is the normal case /// for most operators — the empty state says so plainly. class RunsPage extends StatefulWidget { const RunsPage({super.key}); @override State createState() => _RunsPageState(); } /// Why the runs monitor could not load its list. The page used to /// fold every failure into "hub not reachable", which contradicted /// the sidebar's green "connected" dot whenever the hub answered /// with an RPC-level error (usertest finding: a 0.21 hub without /// the ListInvocations RPC). Top-level so the unit test drives the /// classification directly. enum RunsLoadIssue { /// The hub itself cannot be reached (socket-level failure or /// gRPC UNAVAILABLE / DEADLINE_EXCEEDED). unreachable, /// The hub answered, but its version predates the /// ListInvocations RPC (gRPC UNIMPLEMENTED) — connected, just /// too old for this view. unsupported, /// The hub answered and refused because detached invocations /// are switched off (gRPC FAILED_PRECONDITION from the detached /// gate) — show the regular feature-off empty state, not a /// connection error. featureDisabled, /// Anything else — show the friendly error with copyable detail. other, } /// Classify a [HubService.listDetachedRuns] failure into the view /// state the page should render. The runs-specific part is the /// detached feature gate; everything else delegates to the shared /// page-load classification. RunsLoadIssue classifyRunsLoadError(Object error) { if (grpcCodeOf(error) == 9) { // FAILED_PRECONDITION — the hub's detached gate. final msg = (grpcMessageOf(error) ?? '').toLowerCase(); return msg.contains('detached') ? RunsLoadIssue.featureDisabled : RunsLoadIssue.other; } return switch (classifyHubLoadError(error)) { HubLoadIssue.unreachable => RunsLoadIssue.unreachable, HubLoadIssue.unsupported => RunsLoadIssue.unsupported, HubLoadIssue.other => RunsLoadIssue.other, }; } /// The error rendition of the runs monitor — public + callback-driven /// so the widget test can pump each variant without a live hub. class RunsLoadErrorView extends StatelessWidget { final Object error; final RunsLoadIssue issue; /// Opens the runs guide (the feature-off empty state's CTA). final VoidCallback onOpenGuide; /// Opens the doctor page (the "hub too old" state's secondary CTA, /// and the fallback when the update cannot run from here). Null /// hides the button. final VoidCallback? onOpenDoctor; /// Applies the hub update in place (the "hub too old" state's /// primary CTA). Null falls back to the doctor deeplink only — /// keeps the widget test's no-hub path unchanged. final VoidCallback? onUpdateHub; /// Whether an update is currently being applied — drives the /// spinner on the primary button. final bool updating; const RunsLoadErrorView({ super.key, required this.error, required this.issue, required this.onOpenGuide, this.onOpenDoctor, this.onUpdateHub, this.updating = false, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; switch (issue) { case RunsLoadIssue.featureDisabled: // Same story as the regular feature-off empty state: the // hub is fine, the operator just has not enabled the // feature — never claim "not reachable" here. return ChainEmptyState( icon: Icons.rocket_launch_outlined, title: l.runsEmptyTitle, hint: l.runsEmptyHint, action: OutlinedButton.icon( icon: const Icon(Icons.menu_book_outlined, size: 16), label: Text(l.runsEmptyGuideButton), onPressed: onOpenGuide, ), ); case RunsLoadIssue.unsupported: // The whole point of this state is "your hub is too old" — // so lead with the fix, not with a detour. The primary // button updates the hub in place (same `chain update apply` // path as the Diagnose page); "Diagnose öffnen" stays as the // secondary escape hatch for when there is no local CLI or // the release host is unreachable. return ChainEmptyState( icon: Icons.system_update_alt_outlined, title: l.runsHubTooOldTitle, hint: l.runsHubTooOldHint, action: onUpdateHub == null ? (onOpenDoctor == null ? null : OutlinedButton.icon( icon: const Icon( Icons.health_and_safety_outlined, size: 16, ), label: Text(l.runsHubTooOldButton), onPressed: onOpenDoctor, )) : Column( mainAxisSize: MainAxisSize.min, children: [ FilledButton.icon( onPressed: updating ? null : onUpdateHub, icon: updating ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.system_update_alt, size: 16), label: Text( updating ? l.runsHubUpdateStarted : l.runsHubUpdateButton, ), ), if (onOpenDoctor != null) ...[ const SizedBox(height: ChainSpace.sm), TextButton.icon( icon: const Icon( Icons.health_and_safety_outlined, size: 16, ), label: Text(l.runsHubTooOldButton), onPressed: updating ? null : onOpenDoctor, ), ], ], ), ); case RunsLoadIssue.unreachable: return ChainEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: l.hubUnreachable, hint: l.hubUnreachableHint, ); case RunsLoadIssue.other: return ChainEmptyState( icon: Icons.error_outline, iconColor: theme.colorScheme.error, title: l.runsLoadFailedTitle, action: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: ChainErrorBox(error: error, isError: true), ), ); } } } class _RunsPageState extends State { List _runs = const []; bool _detachedEnabled = false; Object? _error; RunsLoadIssue _issue = RunsLoadIssue.other; bool _loaded = false; Timer? _poll; bool _updatingHub = false; final Set _cancelling = {}; @override void initState() { super.initState(); Workspace.instance.addListener(_refresh); Workspace.instance.ensureLoaded(); _refresh(); // A run's phase changes without user action, so poll — same 2 s // tick the Audit page uses. _poll = Timer.periodic(const Duration(seconds: 2), (_) => _refresh()); } @override void dispose() { Workspace.instance.removeListener(_refresh); _poll?.cancel(); super.dispose(); } Future _refresh() async { try { final snapshot = await HubService.instance.listDetachedRuns( project: Workspace.instance.activeSlug, ); if (!mounted) return; setState(() { _runs = snapshot.runs; _detachedEnabled = snapshot.enabled; _error = null; _loaded = true; }); } catch (e) { if (!mounted) return; setState(() { _error = e; _issue = classifyRunsLoadError(e); _loaded = true; }); } } Future _cancel(DetachedRun run) async { final l = AppLocalizations.of(context)!; setState(() => _cancelling.add(run.id)); try { final ok = await HubService.instance.cancelDetachedRun(run.id); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( ok ? l.runsCancelSignalled(run.flowName) : l.runsCancelTooLate, ), ), ); await _refresh(); } catch (e) { if (!mounted) return; showChainErrorSnack(context, 'runs.cancel', e); } finally { if (mounted) setState(() => _cancelling.remove(run.id)); } } /// Apply the hub update in place from the "hub too old" state. /// Uses the same `chain update apply --channel ` path as the /// Diagnose page. When there is no local CLI, or the release host /// offers no update, we hand off to the Diagnose page, which /// already renders the offline / locate-binary recovery cleanly /// instead of guessing here. Future _updateHub() async { final shell = StudioShellState.of(context); if (!SystemActions.chainBinaryExists()) { shell?.navigateTo('doctor'); return; } setState(() => _updatingHub = true); try { final status = await HubService.instance.checkHubUpdate(); if (!mounted) return; if (status == null) { // No update on offer, or the release host is unreachable — // the Diagnose page states the exact reason. setState(() => _updatingHub = false); shell?.navigateTo('doctor'); return; } final r = await SystemActions.chainUpdateApply(status.channel); if (!mounted) return; setState(() => _updatingHub = false); if (r.ok) { final l = AppLocalizations.of(context)!; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.doctorApplyDone))); // The CLI restarts the daemon; the 2 s poll picks up the // now-supported RPC on its own, but nudge it immediately. await _refresh(); } else { showChainErrorSnack( context, 'runs.update', (r.stderr.isEmpty ? r.stdout : r.stderr).trim(), ); } } catch (e) { if (!mounted) return; setState(() => _updatingHub = false); showChainErrorSnack(context, 'runs.update', e); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: Text(l.runsTitle), actions: [ const ChainWorkspaceSwitcher(), const SizedBox(width: ChainSpace.md), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: l.helpTooltip, onPressed: () => showFaiDoc(context, 'runs'), ), IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: l.runsReloadTooltip, onPressed: _refresh, ), const SizedBox(width: ChainSpace.sm), ], ), body: !_loaded ? const Center(child: CircularProgressIndicator()) : _error != null && _runs.isEmpty ? RunsLoadErrorView( error: _error!, issue: _issue, onOpenGuide: () => showFaiDoc(context, 'runs'), onOpenDoctor: () => StudioShellState.of(context)?.navigateTo('doctor'), onUpdateHub: _updateHub, updating: _updatingHub, ) : _runs.isEmpty ? ChainEmptyState( icon: Icons.rocket_launch_outlined, title: l.runsEmptyTitle, // The hub reports whether the operator enabled the // feature — never claim "switched off" while it is on // and there simply are no runs yet (usertest finding). hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint, // The guide carries the plain-language explanation plus // the exact operator steps (config snippet) — a click // target instead of a raw config key in the hint. action: OutlinedButton.icon( icon: const Icon(Icons.menu_book_outlined, size: 16), label: Text(l.runsEmptyGuideButton), onPressed: () => showFaiDoc(context, 'runs'), ), ) : ListView.separated( padding: const EdgeInsets.all(ChainSpace.lg), itemCount: _runs.length, separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.sm), itemBuilder: (context, i) => _RunRow( run: _runs[i], cancelling: _cancelling.contains(_runs[i].id), onCancel: () => _cancel(_runs[i]), ), ), ); } } class _RunRow extends StatelessWidget { final DetachedRun run; final bool cancelling; final VoidCallback onCancel; const _RunRow({ required this.run, required this.cancelling, required this.onCancel, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final (tone, label) = _phaseChip(l); return ChainCard( child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( run.flowName.isEmpty ? run.id : run.flowName, style: theme.textTheme.titleSmall, ), const SizedBox(width: ChainSpace.sm), ChainPill(label: label, tone: tone), ], ), const SizedBox(height: 4), Text( _subtitle(l), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), if (run.error.isNotEmpty) ...[ const SizedBox(height: 4), SelectableText( run.error, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], ], ), ), if (run.isCancellable) cancelling ? const Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), ) : OutlinedButton.icon( onPressed: onCancel, icon: const Icon(Icons.stop_circle_outlined, size: 16), label: Text(l.runsCancelButton), ), ], ), ); } String _subtitle(AppLocalizations l) { final parts = []; if (run.currentStep.isNotEmpty) { parts.add(l.runsCurrentStep(run.currentStep)); } if (run.project.isNotEmpty) parts.add(run.project); parts.add(run.id); return parts.join(' · '); } (ChainPillTone, String) _phaseChip(AppLocalizations l) { switch (run.phase) { case DetachedPhase.pending: return (ChainPillTone.neutral, l.runsPhasePending); case DetachedPhase.running: return (ChainPillTone.accent, l.runsPhaseRunning); case DetachedPhase.succeeded: return (ChainPillTone.success, l.runsPhaseSucceeded); case DetachedPhase.failed: return (ChainPillTone.danger, l.runsPhaseFailed); case DetachedPhase.cancelled: return (ChainPillTone.warning, l.runsPhaseCancelled); case DetachedPhase.unknown: return (ChainPillTone.neutral, l.runsPhaseUnknown); } } }