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

@ -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);
});
}