feat: detached-runs monitor page (T3 parity)
Some checks failed
Security / Security check (push) Failing after 1s

New Runs sidebar destination listing detached invocations (detach:true)
with phase, current step, project and a Cancel button while
pending/running. Workspace-scoped like Audit/Approvals, polls every 2s.
Detached runs are opt-in (detached.enabled) — the empty state explains
how to enable them. Inline help doc DE+EN. DetachedRun model +
listDetachedRuns/cancelDetachedRun in HubService, backed by the SDK's
listInvocations()/cancelInvocation(). flutter analyze clean; 29 tests
green (sidebar Y-stability updated for the new destination, model
mapping unit-tested).

Screenshot verification (light+dark) deferred — shared desktop in use.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 14:43:37 +02:00
parent 984c91f91d
commit 54ccd3936a
13 changed files with 764 additions and 2 deletions

View file

@ -1017,6 +1017,23 @@ class HubService {
Future<void> 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<List<DetachedRun>> 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<bool> cancelDetachedRun(String id) =>
_client.cancelInvocation(id);
/// Federation satellites currently connected to this hub
/// (primary side). Empty when none are connected.
Future<List<Satellite>> 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;