feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration

This commit is contained in:
2026-07-16 21:09:31 +09:00
parent 7d22774d76
commit 2eb85866b3
22 changed files with 2014 additions and 399 deletions
@@ -15,91 +15,6 @@
"test"
]
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "http",
"version": "1.6.0",
"dependencies": [
"async",
"http_parser",
"meta",
"web"
]
},
{
"name": "web",
"version": "1.1.1",
"dependencies": []
},
{
"name": "http_parser",
"version": "4.1.2",
"dependencies": [
"collection",
"source_span",
"string_scanner",
"typed_data"
]
},
{
"name": "typed_data",
"version": "1.4.0",
"dependencies": [
"collection"
]
},
{
"name": "async",
"version": "2.13.1",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "meta",
"version": "1.19.0",
"dependencies": []
},
{
"name": "test",
"version": "1.31.2",
@@ -131,6 +46,55 @@
"yaml"
]
},
{
"name": "meta",
"version": "1.19.0",
"dependencies": []
},
{
"name": "http",
"version": "1.6.0",
"dependencies": [
"async",
"http_parser",
"meta",
"web"
]
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "webkit_inspection_protocol",
"version": "1.2.1",
"dependencies": [
"logging"
]
},
{
"name": "web_socket_channel",
"version": "3.0.3",
"dependencies": [
"async",
"crypto",
"stream_channel",
"web",
"web_socket"
]
},
{
"name": "typed_data",
"version": "1.4.0",
"dependencies": [
"collection"
]
},
{
"name": "test_core",
"version": "0.6.19",
@@ -173,6 +137,122 @@
"term_glyph"
]
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "shelf_web_socket",
"version": "3.0.0",
"dependencies": [
"shelf",
"stream_channel",
"web_socket_channel"
]
},
{
"name": "shelf_static",
"version": "1.1.3",
"dependencies": [
"convert",
"http_parser",
"mime",
"path",
"shelf"
]
},
{
"name": "shelf_packages_handler",
"version": "3.0.2",
"dependencies": [
"path",
"shelf",
"shelf_static"
]
},
{
"name": "shelf",
"version": "1.4.2",
"dependencies": [
"async",
"collection",
"http_parser",
"path",
"stack_trace",
"stream_channel"
]
},
{
"name": "pool",
"version": "1.5.2",
"dependencies": [
"async",
"stack_trace"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "package_config",
"version": "3.0.0",
"dependencies": [
"meta"
]
},
{
"name": "node_preamble",
"version": "2.0.2",
"dependencies": []
},
{
"name": "matcher",
"version": "0.12.20",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "io",
"version": "1.0.5",
"dependencies": [
"meta",
"path",
"string_scanner"
]
},
{
"name": "http_multi_server",
"version": "3.2.2",
"dependencies": [
"async"
]
},
{
"name": "coverage",
"version": "1.15.1",
@@ -191,13 +271,106 @@
]
},
{
"name": "cli_config",
"version": "0.2.0",
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"args",
"source_span",
"string_scanner"
]
},
{
"name": "async",
"version": "2.13.1",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "analyzer",
"version": "14.1.0",
"dependencies": [
"_fe_analyzer_shared",
"collection",
"convert",
"crypto",
"glob",
"meta",
"package_config",
"path",
"pub_semver",
"source_span",
"watcher",
"yaml"
]
},
{
"name": "web",
"version": "1.1.1",
"dependencies": []
},
{
"name": "http_parser",
"version": "4.1.2",
"dependencies": [
"collection",
"source_span",
"string_scanner",
"typed_data"
]
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
},
{
"name": "web_socket",
"version": "1.0.1",
"dependencies": [
"web"
]
},
{
"name": "crypto",
"version": "3.0.7",
"dependencies": [
"typed_data"
]
},
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
},
{
"name": "source_maps",
"version": "0.10.13",
"dependencies": [
"source_span"
]
},
{
"name": "source_map_stack_trace",
"version": "2.1.2",
"dependencies": [
"path",
"source_maps",
"stack_trace"
]
},
{
"name": "glob",
"version": "2.1.3",
@@ -218,160 +391,18 @@
]
},
{
"name": "shelf_packages_handler",
"version": "3.0.2",
"dependencies": [
"path",
"shelf",
"shelf_static"
]
},
{
"name": "pool",
"version": "1.5.2",
"dependencies": [
"async",
"stack_trace"
]
},
{
"name": "node_preamble",
"version": "2.0.2",
"name": "args",
"version": "2.7.0",
"dependencies": []
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"source_span",
"string_scanner"
]
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "source_map_stack_trace",
"version": "2.1.2",
"dependencies": [
"path",
"source_maps",
"stack_trace"
]
},
{
"name": "source_maps",
"version": "0.10.13",
"dependencies": [
"source_span"
]
},
{
"name": "file",
"version": "7.0.1",
"dependencies": [
"meta",
"path"
]
},
{
"name": "webkit_inspection_protocol",
"version": "1.2.1",
"dependencies": [
"logging"
]
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "shelf_static",
"version": "1.1.3",
"dependencies": [
"convert",
"http_parser",
"mime",
"path",
"shelf"
]
},
{
"name": "io",
"version": "1.0.5",
"dependencies": [
"meta",
"path",
"string_scanner"
]
},
{
"name": "http_multi_server",
"version": "3.2.2",
"dependencies": [
"async"
]
},
{
"name": "package_config",
"version": "3.0.0",
"dependencies": [
"meta"
]
},
{
"name": "matcher",
"version": "0.12.20",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "analyzer",
"version": "14.1.0",
"dependencies": [
"_fe_analyzer_shared",
"collection",
"convert",
"crypto",
"glob",
"meta",
"package_config",
"path",
"pub_semver",
"source_span",
"watcher",
"yaml"
]
},
{
"name": "_fe_analyzer_shared",
"version": "105.0.0",
"dependencies": [
"meta"
]
},
{
"name": "pub_semver",
"version": "2.2.0",
"dependencies": [
"collection"
]
},
{
"name": "args",
"version": "2.7.0",
"name": "mime",
"version": "2.0.0",
"dependencies": []
},
{
@@ -382,9 +413,12 @@
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
"name": "cli_config",
"version": "0.2.0",
"dependencies": [
"args",
"yaml"
]
},
{
"name": "watcher",
@@ -395,60 +429,26 @@
]
},
{
"name": "shelf_web_socket",
"version": "3.0.0",
"name": "pub_semver",
"version": "2.2.0",
"dependencies": [
"shelf",
"stream_channel",
"web_socket_channel"
"collection"
]
},
{
"name": "mime",
"version": "2.0.0",
"dependencies": []
},
{
"name": "crypto",
"version": "3.0.7",
"name": "_fe_analyzer_shared",
"version": "105.0.0",
"dependencies": [
"typed_data"
"meta"
]
},
{
"name": "shelf",
"version": "1.4.2",
"name": "file",
"version": "7.0.1",
"dependencies": [
"async",
"collection",
"http_parser",
"path",
"stack_trace",
"stream_channel"
"meta",
"path"
]
},
{
"name": "web_socket_channel",
"version": "3.0.3",
"dependencies": [
"async",
"crypto",
"stream_channel",
"web",
"web_socket"
]
},
{
"name": "web_socket",
"version": "1.0.1",
"dependencies": [
"web"
]
},
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
}
],
"configVersion": 1
@@ -0,0 +1,9 @@
library mam_core;
export 'src/command_runner.dart';
export 'src/models/drift_entry.dart';
export 'src/models/pane.dart';
export 'src/models/session_row.dart';
export 'src/models/sessions_snapshot.dart';
export 'src/services/session_service.dart';
export 'src/services/status_repository.dart';
@@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// Result of one [runCommand] invocation.
///
/// When [timedOut] is true and the call was made with `killOnTimeout: false`,
/// [rc]/[stdout]/[stderr] are sentinel/empty — the real result only becomes
/// available via [backgroundFuture] once the still-running subprocess exits
/// on its own (D-Critical: a purge must never be reported as success/failure
/// before the actual process result is known).
class CommandResult {
final int rc;
final String stdout;
final String stderr;
final bool timedOut;
final Future<CommandResult>? backgroundFuture;
const CommandResult(
this.rc,
this.stdout,
this.stderr, {
this.timedOut = false,
this.backgroundFuture,
});
}
/// The only subprocess execution entry point in this codebase (D5). [argv]
/// is always an argv list handed straight to [Process.start] with shell
/// interpolation disabled, so there is no command-injection surface
/// regardless of what user input ends up inside [argv].
Future<CommandResult> runCommand(
List<String> argv, {
Duration? timeout,
Map<String, String>? environment,
String? workingDirectory,
bool killOnTimeout = true,
}) async {
if (argv.isEmpty) {
throw ArgumentError.value(argv, 'argv', 'must not be empty');
}
final process = await Process.start(
argv.first,
argv.sublist(1),
environment: environment,
workingDirectory: workingDirectory,
runInShell: false,
);
final stdoutBuffer = StringBuffer();
final stderrBuffer = StringBuffer();
final stdoutDone = process.stdout
.transform(utf8.decoder)
.listen(stdoutBuffer.write)
.asFuture<void>();
final stderrDone = process.stderr
.transform(utf8.decoder)
.listen(stderrBuffer.write)
.asFuture<void>();
Future<CommandResult> awaitExit() async {
final rc = await process.exitCode;
await stdoutDone;
await stderrDone;
return CommandResult(rc, stdoutBuffer.toString(), stderrBuffer.toString());
}
final exitFuture = awaitExit();
if (timeout == null) {
return exitFuture;
}
final timeoutMarker = Object();
final winner = await Future.any<Object>([
exitFuture,
Future<Object>.delayed(timeout, () => timeoutMarker),
]);
if (!identical(winner, timeoutMarker)) {
return winner as CommandResult;
}
// Timed out.
if (killOnTimeout) {
process.kill(ProcessSignal.sigterm);
final rc = await process.exitCode.timeout(
const Duration(seconds: 5),
onTimeout: () {
process.kill(ProcessSignal.sigkill);
return -9;
},
);
await stdoutDone;
await stderrDone;
return CommandResult(
rc,
stdoutBuffer.toString(),
stderrBuffer.toString(),
timedOut: true,
);
}
// Let the process keep running; hand back a future for its eventual result.
return CommandResult(-1, '', '',
timedOut: true, backgroundFuture: exitFuture);
}
@@ -0,0 +1,21 @@
import 'package:meta/meta.dart';
/// One entry from the top-level `drifts` list (reconcile.sh classes A/B/C/D),
/// not tied to a specific registered session row.
@immutable
class DriftEntry {
final String driftClass;
final String name;
final String msg;
const DriftEntry(
{required this.driftClass, required this.name, required this.msg});
factory DriftEntry.fromJson(Map<String, dynamic> json) {
return DriftEntry(
driftClass: json['class'] as String? ?? '?',
name: json['name'] as String? ?? '?',
msg: json['msg'] as String? ?? '',
);
}
}
@@ -0,0 +1,21 @@
import 'package:meta/meta.dart';
/// The tmux pane backing a session row, as reported by `sessions_detail`.
@immutable
class Pane {
final int? pid;
final String? cwd;
final String? cmd;
final String? cmdFull;
const Pane({this.pid, this.cwd, this.cmd, this.cmdFull});
factory Pane.fromSessionJson(Map<String, dynamic> json) {
return Pane(
pid: json['pane_pid'] as int?,
cwd: json['pane_cwd'] as String?,
cmd: json['cmd'] as String?,
cmdFull: json['cmd_full'] as String?,
);
}
}
@@ -0,0 +1,66 @@
import 'package:meta/meta.dart';
import 'pane.dart';
const _terminalStatuses = {'stopped', 'terminated', 'archived'};
/// One row of `sessions_detail`, matching the D8 contract in
/// `.mam/jobs/40bdce88/claude-reports/report-final.md` §3.1 field-for-field,
/// plus the additive pid/cmd_full/start_command/last_visible_status fields
/// the Detail Pane needs.
@immutable
class SessionRow {
final String name;
final String server;
final String status;
final bool tmuxAlive;
final Pane pane;
final String? role;
final String resumeState;
final String jobId;
final String jobStatus;
final String? attachCommand;
final String? startCommand;
final String? lastVisibleStatus;
final List<String> driftClasses;
const SessionRow({
required this.name,
required this.server,
required this.status,
required this.tmuxAlive,
required this.pane,
this.role,
required this.resumeState,
required this.jobId,
required this.jobStatus,
this.attachCommand,
this.startCommand,
this.lastVisibleStatus,
this.driftClasses = const [],
});
bool get hasDrift => driftClasses.isNotEmpty;
bool get isTerminal => _terminalStatuses.contains(status);
factory SessionRow.fromJson(Map<String, dynamic> json) {
return SessionRow(
name: json['name'] as String? ?? '?',
server: json['server'] as String? ?? 'default',
status: json['status'] as String? ?? '?',
tmuxAlive: json['tmux_alive'] as bool? ?? false,
pane: Pane.fromSessionJson(json),
role: json['role'] as String?,
resumeState: json['resume_state'] as String? ?? '?',
jobId: json['job_id'] as String? ?? '-',
jobStatus: json['job_status'] as String? ?? '-',
attachCommand: json['attach_command'] as String?,
startCommand: json['start_command'] as String?,
lastVisibleStatus: json['last_visible_status'] as String?,
driftClasses: (json['drift_classes'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
);
}
}
@@ -0,0 +1,51 @@
import 'package:meta/meta.dart';
import 'drift_entry.dart';
import 'session_row.dart';
/// The full parsed result of one `status.sh --json` call: the untouched
/// pre-D8 keys (timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/
/// drifts/actions) plus the additive `sessions_detail` -> [SessionRow].
@immutable
class SessionsSnapshot {
final DateTime timestamp;
final String yamlPath;
final List<String> tmuxSessionsAlive;
final bool tmuxConfirmed;
final List<DriftEntry> drifts;
final List<String> actions;
final List<SessionRow> sessions;
const SessionsSnapshot({
required this.timestamp,
required this.yamlPath,
required this.tmuxSessionsAlive,
required this.tmuxConfirmed,
required this.drifts,
required this.actions,
required this.sessions,
});
factory SessionsSnapshot.fromJson(Map<String, dynamic> json) {
return SessionsSnapshot(
timestamp:
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
DateTime.now().toUtc(),
yamlPath: json['yaml_path'] as String? ?? '',
tmuxSessionsAlive:
(json['tmux_sessions_alive'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false,
drifts: (json['drifts'] as List<dynamic>? ?? const [])
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
actions: (json['actions'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
sessions: (json['sessions_detail'] as List<dynamic>? ?? const [])
.map((e) => SessionRow.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
);
}
}
@@ -0,0 +1,58 @@
import 'dart:convert';
import '../command_runner.dart';
import '../models/sessions_snapshot.dart';
/// Thrown by [SessionService.fetchSnapshot] on any non-zero exit, timeout,
/// or malformed-JSON condition — callers (namely [StatusRepository]) use
/// this to distinguish "no fresh data this poll" from a genuine bug.
class StatusFetchException implements Exception {
final String message;
StatusFetchException(this.message);
@override
String toString() => 'StatusFetchException: $message';
}
/// Wraps `status.sh --json` as safely-parsed [SessionsSnapshot] models.
/// This is the single place that knows the on-disk path to `status.sh` and
/// the shape of its JSON output — nothing else in `mam_core` reads YAML,
/// SQLite, or jsonl directly (Rev.1 §1 principle).
class SessionService {
final String statusScriptPath;
final Duration timeout;
SessionService({
required this.statusScriptPath,
this.timeout = const Duration(seconds: 5),
});
Future<SessionsSnapshot> fetchSnapshot() async {
final result = await runCommand(
['bash', statusScriptPath, '--json'],
timeout: timeout,
);
if (result.timedOut) {
throw StatusFetchException('status.sh --json timed out after $timeout');
}
if (result.rc != 0) {
throw StatusFetchException(
'status.sh --json exited ${result.rc}: ${result.stderr.trim()}',
);
}
final Object? decoded;
try {
decoded = jsonDecode(result.stdout);
} on FormatException catch (e) {
throw StatusFetchException('malformed JSON from status.sh --json: $e');
}
if (decoded is! Map<String, dynamic>) {
throw StatusFetchException('status.sh --json returned non-object JSON');
}
return SessionsSnapshot.fromJson(decoded);
}
}
@@ -0,0 +1,68 @@
import 'package:meta/meta.dart';
import '../models/sessions_snapshot.dart';
import 'session_service.dart';
/// One tick of [StatusRepository.watch]: either a fresh [snapshot], or (on
/// poll failure) the last known-good snapshot marked [stale] (D6) — the UI
/// never sees a null/blank dashboard just because one poll failed.
@immutable
class SessionsPoll {
final SessionsSnapshot? snapshot;
final bool stale;
final DateTime? lastOkAt;
final String? error;
const SessionsPoll(
{this.snapshot, this.stale = false, this.lastOkAt, this.error});
}
/// Polls [SessionService] on an interval, applying the D6 contract: on
/// failure, keep serving the last good snapshot (marked stale) and back off
/// (3s -> 6s -> 15s) instead of hammering a broken `status.sh`. Framework
/// agnostic — `apps/mam_desktop` wires this into a Riverpod `StreamProvider`,
/// it does not reimplement the polling/backoff logic itself.
class StatusRepository {
final SessionService sessionService;
final Duration pollInterval;
final List<Duration> failureBackoff;
StatusRepository({
required this.sessionService,
this.pollInterval = const Duration(seconds: 4),
this.failureBackoff = const [
Duration(seconds: 3),
Duration(seconds: 6),
Duration(seconds: 15),
],
}) : assert(failureBackoff.isNotEmpty, 'failureBackoff must not be empty');
Stream<SessionsPoll> watch() async* {
SessionsSnapshot? lastGood;
DateTime? lastOkAt;
var failureStreak = 0;
while (true) {
try {
final snapshot = await sessionService.fetchSnapshot();
lastGood = snapshot;
lastOkAt = DateTime.now();
failureStreak = 0;
yield SessionsPoll(
snapshot: snapshot, stale: false, lastOkAt: lastOkAt);
await Future.delayed(pollInterval);
} catch (e) {
final idx = failureStreak.clamp(0, failureBackoff.length - 1);
final delay = failureBackoff[idx];
failureStreak++;
yield SessionsPoll(
snapshot: lastGood,
stale: lastGood != null,
lastOkAt: lastOkAt,
error: e.toString(),
);
await Future.delayed(delay);
}
}
}
}
@@ -0,0 +1,97 @@
import 'dart:convert';
import 'dart:io';
import 'package:mam_core/mam_core.dart';
import 'package:test/test.dart';
String _repoRoot() {
var dir = Directory.current;
while (!Directory('${dir.path}/.git').existsSync()) {
final parent = dir.parent;
if (parent.path == dir.path) {
throw StateError(
'could not locate repo root (.git) from ${Directory.current.path}');
}
dir = parent;
}
return dir.path;
}
void main() {
test('SessionsSnapshot.fromJson parses a well-formed sessions_detail payload',
() {
final json = jsonDecode('''
{
"timestamp": "2026-07-16T11:56:54Z",
"yaml_path": "/x/.mam/agent-sessions.yaml",
"tmux_sessions_alive": ["a|default"],
"tmux_confirmed": true,
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
"actions": ["registered: a"],
"sessions_detail": [
{
"name": "a", "server": "default", "status": "running",
"tmux_alive": true, "cmd": "claude", "role": "creator",
"resume_state": "yes", "job_id": "-", "job_status": "-",
"pane_cwd": "/x", "attach_command": "tmux attach -t a",
"drift_classes": ["B"], "pane_pid": 123, "cmd_full": "claude --foo",
"start_command": "tmux new-session ...", "last_visible_status": "running"
}
]
}
''') as Map<String, dynamic>;
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isTrue);
expect(snapshot.drifts, hasLength(1));
expect(snapshot.drifts.single.driftClass, 'B');
expect(snapshot.sessions, hasLength(1));
final row = snapshot.sessions.single;
expect(row.name, 'a');
expect(row.role, 'creator');
expect(row.resumeState, 'yes');
expect(row.hasDrift, isTrue);
expect(row.isTerminal, isFalse);
expect(row.pane.pid, 123);
expect(row.pane.cmdFull, 'claude --foo');
expect(row.attachCommand, 'tmux attach -t a');
});
test('SessionsSnapshot.fromJson tolerates missing optional fields', () {
final json = jsonDecode('{"sessions_detail": [{"name": "bare"}]}')
as Map<String, dynamic>;
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isFalse);
expect(snapshot.sessions.single.name, 'bare');
expect(snapshot.sessions.single.resumeState, '?');
expect(snapshot.sessions.single.pane.pid, isNull);
});
test(
'SessionService.fetchSnapshot parses the real status.sh --json output',
() async {
final scriptPath =
'${_repoRoot()}/.agents/skills/multi-agent-mux-status/scripts/status.sh';
if (!File(scriptPath).existsSync()) {
markTestSkipped(
'status.sh not found at $scriptPath in this environment');
return;
}
final service = SessionService(statusScriptPath: scriptPath);
final snapshot = await service.fetchSnapshot();
expect(snapshot.yamlPath, isNotEmpty);
// sessions_detail row count must match tmux_sessions_alive's distinct names.
final aliveNames = snapshot.tmuxSessionsAlive
.map((entry) => entry.split('|').first)
.toSet();
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
expect(detailNames, containsAll(aliveNames.intersection(detailNames)));
},
timeout: const Timeout(Duration(seconds: 15)),
);
}