diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f07d3..aafcd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Added (detached-runs monitor — T3 parity) + +- **Runs page.** A new sidebar destination lists detached invocations + (submitted with `detach: true`) with their phase, current step, + project and a **Cancel** button while pending/running. Workspace- + scoped like Audit and Approvals; polls every 2 s. Detached runs are + opt-in (`detached.enabled`), so the empty state explains how to turn + them on. Inline help doc (DE+EN). Backed by the SDK's + `listInvocations()` + `cancelInvocation()`. + ### Added (multi-project, stages ① + ②) - **Workspace switcher.** The Audit and Approvals AppBars carry a diff --git a/assets/docs/runs.md b/assets/docs/runs.md new file mode 100644 index 0000000..7633572 --- /dev/null +++ b/assets/docs/runs.md @@ -0,0 +1,45 @@ +# Runs + +The Runs page monitors **detached invocations** — flows submitted +to run in the background rather than being waited on. Each row shows +the flow, its current phase, the step it is on, and the project it +was stamped with. + +## What a detached run is + +Most flows run *inline*: you start them and wait for the result. A +**detached** run is handed to the hub and returns an id immediately; +the flow keeps running in the background. You come back later to +read its result, follow it live, or cancel it. + +Detached invocations are **opt-in**. They only work when the +operator enabled them in the hub config: + +```yaml +detached: + enabled: true +``` + +Until then this page is empty — that is the normal state. + +## Phases + +- **Pending** — accepted, not yet executing. +- **Running** — a step is executing (the row names it). +- **Succeeded** — finished; the result is retained under the hub's + size/count/TTL limits. +- **Failed** — finished with an error (shown on the row). +- **Cancelled** — stopped by an operator. + +## Cancelling + +A pending or running detached run carries a **Cancel** button. +Cancelling signals the run to stop; a run that already finished +cannot be cancelled (Studio says so rather than pretending). + +## Workspace scope + +The page respects the active workspace: with a project selected it +shows only that project's detached runs. Detached results are held +in memory for the current hub process — they are not retained across +a hub restart. diff --git a/assets/docs/runs_de.md b/assets/docs/runs_de.md new file mode 100644 index 0000000..6117b15 --- /dev/null +++ b/assets/docs/runs_de.md @@ -0,0 +1,48 @@ +# Läufe + +Die Läufe-Seite überwacht **abgekoppelte Aufrufe** — Flows, die im +Hintergrund laufen, statt auf ihr Ergebnis zu warten. Jede Zeile +zeigt den Flow, seine Phase, den aktuellen Schritt und das Projekt, +mit dem er gestempelt wurde. + +## Was ein abgekoppelter Lauf ist + +Die meisten Flows laufen *inline*: Man startet sie und wartet auf +das Ergebnis. Ein **abgekoppelter** Lauf wird dem Hub übergeben und +liefert sofort eine Kennung zurück; der Flow läuft im Hintergrund +weiter. Man kommt später zurück, um das Ergebnis zu lesen, dem Lauf +live zu folgen oder ihn abzubrechen. + +Abgekoppelte Aufrufe sind **optional**. Sie funktionieren nur, wenn +der Betreiber sie in der Hub-Konfiguration aktiviert hat: + +```yaml +detached: + enabled: true +``` + +Bis dahin ist diese Seite leer — das ist der Normalzustand. + +## Phasen + +- **Wartet** — angenommen, noch nicht in Ausführung. +- **Läuft** — ein Schritt wird ausgeführt (die Zeile nennt ihn). +- **Erfolgreich** — fertig; das Ergebnis wird unter den + Größen-/Anzahl-/TTL-Grenzen des Hubs aufbewahrt. +- **Fehlgeschlagen** — mit einem Fehler beendet (in der Zeile + angezeigt). +- **Abgebrochen** — von einem Betreiber gestoppt. + +## Abbrechen + +Ein wartender oder laufender abgekoppelter Lauf trägt einen +**Abbrechen**-Knopf. Der Abbruch signalisiert dem Lauf zu stoppen; +ein bereits beendeter Lauf lässt sich nicht abbrechen (Studio sagt +das ehrlich, statt es vorzutäuschen). + +## Arbeitsbereich-Filter + +Die Seite berücksichtigt den aktiven Arbeitsbereich: Mit gewähltem +Projekt zeigt sie nur dessen abgekoppelte Läufe. Abgekoppelte +Ergebnisse werden im Speicher des laufenden Hub-Prozesses gehalten — +sie überstehen keinen Hub-Neustart. diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 2578d81..86fd12d 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -1017,6 +1017,23 @@ class HubService { Future reject(String id, String reviewer, String reason) => _client.reject(approvalId: id, reviewer: reviewer, reason: reason); + /// Every tracked detached invocation (newest-first). Optionally + /// scoped to one [project]. Empty when detached invocations are + /// disabled or none have run this process. + Future> listDetachedRuns({String project = ''}) async { + final entries = await _client.listInvocations(); + final runs = entries + .where((e) => project.isEmpty || e.project == project) + .map(DetachedRun.fromEntry) + .toList(); + return runs; + } + + /// Cancel a running/pending detached invocation. Returns true when + /// it was signalled, false if already finished or unknown. + Future cancelDetachedRun(String id) => + _client.cancelInvocation(id); + /// Federation satellites currently connected to this hub /// (primary side). Empty when none are connected. Future> listSatellites() async { @@ -1547,6 +1564,73 @@ class ApprovalRecord { }); } +/// Lifecycle phase of a detached run, mirroring the wire enum. +enum DetachedPhase { pending, running, succeeded, failed, cancelled, unknown } + +/// A detached invocation as shown in the runs monitor. +class DetachedRun { + final String id; + final DetachedPhase phase; + final String flowName; + final String project; + + /// Step currently executing (running), else empty. + final String currentStep; + final DateTime? startedAt; + final DateTime? finishedAt; + + /// Error message when [phase] is failed, else empty. + final String error; + + const DetachedRun({ + required this.id, + required this.phase, + required this.flowName, + required this.project, + required this.currentStep, + required this.startedAt, + required this.finishedAt, + required this.error, + }); + + /// True while the run can still be cancelled. + bool get isCancellable => + phase == DetachedPhase.pending || phase == DetachedPhase.running; + + factory DetachedRun.fromEntry(InvocationEntry e) { + // Map by the proto enum's integer value (PENDING=1 … CANCELLED=5) + // so Studio needn't import the generated enum type. + DetachedPhase mapPhase(int v) { + switch (v) { + case 1: + return DetachedPhase.pending; + case 2: + return DetachedPhase.running; + case 3: + return DetachedPhase.succeeded; + case 4: + return DetachedPhase.failed; + case 5: + return DetachedPhase.cancelled; + default: + return DetachedPhase.unknown; + } + } + + final s = e.status; + return DetachedRun( + id: e.invocationId, + phase: mapPhase(s.phase.value), + flowName: e.flowName, + project: e.project, + currentStep: s.currentStep, + startedAt: s.startedAt.isEmpty ? null : DateTime.tryParse(s.startedAt), + finishedAt: s.finishedAt.isEmpty ? null : DateTime.tryParse(s.finishedAt), + error: s.error, + ); + } +} + class SystemAiStatus { final bool enabled; final String provider; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 860d2ff..1fe3432 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1667,5 +1667,22 @@ "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.", "workspaceAll": "Alle Projekte", "workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt", - "workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich." + "workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.", + "runsTitle": "Läufe", + "runsReloadTooltip": "Lauf-Liste neu laden", + "runsEmptyTitle": "Keine abgekoppelten Läufe", + "runsEmptyHint": "Mit detach gestartete Läufe stehen hier, bis sie fertig sind. Abgekoppelte Aufrufe sind optional — dazu detached.enabled in der Betreiber-Konfiguration aktivieren.", + "runsCancelButton": "Abbrechen", + "runsCancelSignalled": "Abbruch für {flow} ausgelöst.", + "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, + "runsCancelTooLate": "Der Lauf war schon fertig — nichts abzubrechen.", + "runsCurrentStep": "Schritt: {step}", + "@runsCurrentStep": {"placeholders": {"step": {"type": "String"}}}, + "runsPhasePending": "Wartet", + "runsPhaseRunning": "Läuft", + "runsPhaseSucceeded": "Erfolgreich", + "runsPhaseFailed": "Fehlgeschlagen", + "runsPhaseCancelled": "Abgebrochen", + "runsPhaseUnknown": "Unbekannt", + "navRuns": "Läufe" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 28ac56a..1ed4d21 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1706,5 +1706,22 @@ "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.", "workspaceAll": "All projects", "workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project", - "workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area." + "workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.", + "runsTitle": "Runs", + "runsReloadTooltip": "Reload the runs list", + "runsEmptyTitle": "No detached runs", + "runsEmptyHint": "Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.", + "runsCancelButton": "Cancel", + "runsCancelSignalled": "Cancel signalled for {flow}.", + "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, + "runsCancelTooLate": "The run had already finished — nothing to cancel.", + "runsCurrentStep": "Step: {step}", + "@runsCurrentStep": {"placeholders": {"step": {"type": "String"}}}, + "runsPhasePending": "Pending", + "runsPhaseRunning": "Running", + "runsPhaseSucceeded": "Succeeded", + "runsPhaseFailed": "Failed", + "runsPhaseCancelled": "Cancelled", + "runsPhaseUnknown": "Unknown", + "navRuns": "Runs" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 05a5ffc..14bc68f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4908,6 +4908,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'** String get workspaceProtectedHint; + + /// No description provided for @runsTitle. + /// + /// In en, this message translates to: + /// **'Runs'** + String get runsTitle; + + /// No description provided for @runsReloadTooltip. + /// + /// In en, this message translates to: + /// **'Reload the runs list'** + String get runsReloadTooltip; + + /// No description provided for @runsEmptyTitle. + /// + /// In en, this message translates to: + /// **'No detached runs'** + String get runsEmptyTitle; + + /// No description provided for @runsEmptyHint. + /// + /// In en, this message translates to: + /// **'Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.'** + String get runsEmptyHint; + + /// No description provided for @runsCancelButton. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get runsCancelButton; + + /// No description provided for @runsCancelSignalled. + /// + /// In en, this message translates to: + /// **'Cancel signalled for {flow}.'** + String runsCancelSignalled(String flow); + + /// No description provided for @runsCancelTooLate. + /// + /// In en, this message translates to: + /// **'The run had already finished — nothing to cancel.'** + String get runsCancelTooLate; + + /// No description provided for @runsCurrentStep. + /// + /// In en, this message translates to: + /// **'Step: {step}'** + String runsCurrentStep(String step); + + /// No description provided for @runsPhasePending. + /// + /// In en, this message translates to: + /// **'Pending'** + String get runsPhasePending; + + /// No description provided for @runsPhaseRunning. + /// + /// In en, this message translates to: + /// **'Running'** + String get runsPhaseRunning; + + /// No description provided for @runsPhaseSucceeded. + /// + /// In en, this message translates to: + /// **'Succeeded'** + String get runsPhaseSucceeded; + + /// No description provided for @runsPhaseFailed. + /// + /// In en, this message translates to: + /// **'Failed'** + String get runsPhaseFailed; + + /// No description provided for @runsPhaseCancelled. + /// + /// In en, this message translates to: + /// **'Cancelled'** + String get runsPhaseCancelled; + + /// No description provided for @runsPhaseUnknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get runsPhaseUnknown; + + /// No description provided for @navRuns. + /// + /// In en, this message translates to: + /// **'Runs'** + String get navRuns; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 640e462..5008dca 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2882,4 +2882,55 @@ class AppLocalizationsDe extends AppLocalizations { @override String get workspaceProtectedHint => 'Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.'; + + @override + String get runsTitle => 'Läufe'; + + @override + String get runsReloadTooltip => 'Lauf-Liste neu laden'; + + @override + String get runsEmptyTitle => 'Keine abgekoppelten Läufe'; + + @override + String get runsEmptyHint => + 'Mit detach gestartete Läufe stehen hier, bis sie fertig sind. Abgekoppelte Aufrufe sind optional — dazu detached.enabled in der Betreiber-Konfiguration aktivieren.'; + + @override + String get runsCancelButton => 'Abbrechen'; + + @override + String runsCancelSignalled(String flow) { + return 'Abbruch für $flow ausgelöst.'; + } + + @override + String get runsCancelTooLate => + 'Der Lauf war schon fertig — nichts abzubrechen.'; + + @override + String runsCurrentStep(String step) { + return 'Schritt: $step'; + } + + @override + String get runsPhasePending => 'Wartet'; + + @override + String get runsPhaseRunning => 'Läuft'; + + @override + String get runsPhaseSucceeded => 'Erfolgreich'; + + @override + String get runsPhaseFailed => 'Fehlgeschlagen'; + + @override + String get runsPhaseCancelled => 'Abgebrochen'; + + @override + String get runsPhaseUnknown => 'Unbekannt'; + + @override + String get navRuns => 'Läufe'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5a8de81..a89cd5e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2885,4 +2885,55 @@ class AppLocalizationsEn extends AppLocalizations { @override String get workspaceProtectedHint => 'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'; + + @override + String get runsTitle => 'Runs'; + + @override + String get runsReloadTooltip => 'Reload the runs list'; + + @override + String get runsEmptyTitle => 'No detached runs'; + + @override + String get runsEmptyHint => + 'Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.'; + + @override + String get runsCancelButton => 'Cancel'; + + @override + String runsCancelSignalled(String flow) { + return 'Cancel signalled for $flow.'; + } + + @override + String get runsCancelTooLate => + 'The run had already finished — nothing to cancel.'; + + @override + String runsCurrentStep(String step) { + return 'Step: $step'; + } + + @override + String get runsPhasePending => 'Pending'; + + @override + String get runsPhaseRunning => 'Running'; + + @override + String get runsPhaseSucceeded => 'Succeeded'; + + @override + String get runsPhaseFailed => 'Failed'; + + @override + String get runsPhaseCancelled => 'Cancelled'; + + @override + String get runsPhaseUnknown => 'Unknown'; + + @override + String get navRuns => 'Runs'; } diff --git a/lib/main.dart b/lib/main.dart index b5ff834..f91d376 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'pages/audit.dart'; import 'pages/doctor.dart'; import 'pages/federation.dart'; import 'pages/flows.dart'; +import 'pages/runs.dart'; import 'pages/store.dart'; import 'pages/welcome.dart'; import 'theme/theme.dart'; @@ -380,6 +381,12 @@ class StudioShellState extends State { selectedIcon: Icons.inbox, page: ApprovalsPage(), ), + _NavPage( + id: 'runs', + icon: Icons.rocket_launch_outlined, + selectedIcon: Icons.rocket_launch, + page: RunsPage(), + ), _NavPage( id: 'federation', icon: Icons.hub_outlined, @@ -1641,6 +1648,8 @@ class _NavPage { return l.navAudit; case 'approvals': return l.navApprovals; + case 'runs': + return l.navRuns; case 'federation': return l.navFederation; default: diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart new file mode 100644 index 0000000..bdbf2ee --- /dev/null +++ b/lib/pages/runs.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../data/error_presentation.dart'; +import '../data/hub.dart'; +import '../data/workspace.dart'; +import '../l10n/app_localizations.dart'; +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(); +} + +class _RunsPageState extends State { + List _runs = const []; + String? _error; + 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 runs = await HubService.instance.listDetachedRuns( + project: Workspace.instance.activeSlug, + ); + if (!mounted) return; + setState(() { + _runs = runs; + _error = null; + _loaded = true; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _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 + ? ChainEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, + ) + : _runs.isEmpty + ? ChainEmptyState( + icon: Icons.rocket_launch_outlined, + title: l.runsEmptyTitle, + hint: l.runsEmptyHint, + ) + : 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); + } + } +} diff --git a/test/detached_run_test.dart b/test/detached_run_test.dart new file mode 100644 index 0000000..713197e --- /dev/null +++ b/test/detached_run_test.dart @@ -0,0 +1,96 @@ +// DetachedRun model — the phase mapping + cancellable predicate that +// drive the runs monitor's chip tone and the cancel-button visibility. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +// The generated enum type isn't re-exported by the SDK's public +// surface (only the message typedefs are), so build fixtures against +// the generated file directly — test-only. +import 'package:chain_client_sdk/src/generated/chain/v1/hub.pb.dart'; + +InvocationEntry _entry({ + required String id, + required int phaseValue, + String flow = 'demo', + String project = 'general', + String step = '', + String error = '', +}) { + final status = InvocationStatus() + ..phase = InvocationStatus_Phase.valueOf(phaseValue)! + ..currentStep = step + ..startedAt = '2026-07-12T10:00:00Z' + ..error = error; + return InvocationEntry() + ..invocationId = id + ..flowName = flow + ..project = project + ..status = status; +} + +void main() { + test('maps each wire phase to the model phase', () { + expect( + DetachedRun.fromEntry(_entry(id: 'a', phaseValue: 1)).phase, + DetachedPhase.pending, + ); + expect( + DetachedRun.fromEntry(_entry(id: 'b', phaseValue: 2)).phase, + DetachedPhase.running, + ); + expect( + DetachedRun.fromEntry(_entry(id: 'c', phaseValue: 3)).phase, + DetachedPhase.succeeded, + ); + expect( + DetachedRun.fromEntry(_entry(id: 'd', phaseValue: 4)).phase, + DetachedPhase.failed, + ); + expect( + DetachedRun.fromEntry(_entry(id: 'e', phaseValue: 5)).phase, + DetachedPhase.cancelled, + ); + expect( + DetachedRun.fromEntry(_entry(id: 'f', phaseValue: 0)).phase, + DetachedPhase.unknown, + ); + }); + + test('only pending and running runs are cancellable', () { + expect( + DetachedRun.fromEntry(_entry(id: 'a', phaseValue: 1)).isCancellable, + isTrue, + ); + expect( + DetachedRun.fromEntry(_entry(id: 'b', phaseValue: 2)).isCancellable, + isTrue, + ); + for (final terminal in [3, 4, 5]) { + expect( + DetachedRun.fromEntry(_entry(id: 'x', phaseValue: terminal)) + .isCancellable, + isFalse, + ); + } + }); + + test('carries flow name, project, step and error through', () { + final run = DetachedRun.fromEntry( + _entry( + id: 'inv-1', + phaseValue: 4, + flow: 'intake', + project: 'client-a', + step: 'extract', + error: 'boom', + ), + ); + expect(run.id, 'inv-1'); + expect(run.flowName, 'intake'); + expect(run.project, 'client-a'); + expect(run.currentStep, 'extract'); + expect(run.error, 'boom'); + expect(run.startedAt, isNotNull); + }); +} diff --git a/test/sidebar_test.dart b/test/sidebar_test.dart index 15d87e6..4e8df1e 100644 --- a/test/sidebar_test.dart +++ b/test/sidebar_test.dart @@ -23,6 +23,7 @@ const _destinations = [ 'flows', 'audit', 'approvals', + 'runs', 'federation', ];