import 'dart:async'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/friendly_error.dart'; import '../data/hub.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. RunsLoadIssue classifyRunsLoadError(Object error) { switch (grpcCodeOf(error)) { case 12: // UNIMPLEMENTED — hub predates the RPC return RunsLoadIssue.unsupported; case 9: // FAILED_PRECONDITION — the hub's detached gate final msg = (grpcMessageOf(error) ?? '').toLowerCase(); return msg.contains('detached') ? RunsLoadIssue.featureDisabled : RunsLoadIssue.other; case 4: // DEADLINE_EXCEEDED case 14: // UNAVAILABLE return RunsLoadIssue.unreachable; } // Non-gRPC failures: only clear socket-level shapes count as // "unreachable"; everything else keeps its real story. final s = error.toString().toLowerCase(); if (s.contains('socketexception') || s.contains('connection refused') || s.contains('connection terminated') || s.contains('failed to connect')) { return RunsLoadIssue.unreachable; } return 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 CTA, where /// the update banner lives). Null hides the button. final VoidCallback? onOpenDoctor; const RunsLoadErrorView({ super.key, required this.error, required this.issue, required this.onOpenGuide, this.onOpenDoctor, }); @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: return ChainEmptyState( icon: Icons.system_update_alt_outlined, title: l.runsHubTooOldTitle, hint: l.runsHubTooOldHint, action: onOpenDoctor == null ? null : OutlinedButton.icon( icon: const Icon(Icons.health_and_safety_outlined, size: 16), label: Text(l.runsHubTooOldButton), onPressed: 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; 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)); } } @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'), ) : _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); } } }