feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration
This commit is contained in:
@@ -19,15 +19,113 @@ JSON=0
|
||||
# read-only drift snapshot — reconcile.sh --dry-run (no side effects)
|
||||
DRIFT_JSON="$(bash "$RECONCILE" --once --emit-diff --dry-run)"
|
||||
|
||||
if [ "$JSON" = "1" ]; then
|
||||
printf '%s\n' "$DRIFT_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Project root (parent of .agents/) holds the multi-agent-mux-delegate-job .mam registry.
|
||||
# Resolved relative to this script — no hardcoded absolute path (review item 6).
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../" && pwd)"
|
||||
|
||||
if [ "$JSON" = "1" ]; then
|
||||
# D8: --json historically only carried reconcile.sh's drift subset (timestamp/
|
||||
# yaml_path/tmux_sessions_alive/tmux_confirmed/drifts/actions), not the enriched
|
||||
# per-row fields (RESUME/JOB_ID/JOB_STATUS/CMD/attach_command/pane.cwd/...) that
|
||||
# only this script's text-mode block computed. Fixed additively below via a
|
||||
# 'sessions_detail' key — every existing key is passed through untouched, so
|
||||
# any consumer of the pre-fix schema keeps working unmodified.
|
||||
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
|
||||
import os, json, glob
|
||||
|
||||
home = os.environ['HOME_DIR']
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
drift = json.loads(os.environ['DRIFT_JSON'])
|
||||
|
||||
try:
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
except Exception:
|
||||
d = {}
|
||||
|
||||
alive = set(drift.get('tmux_sessions_alive', []))
|
||||
drift_by_name = {}
|
||||
for dr in drift.get('drifts', []):
|
||||
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
|
||||
|
||||
|
||||
def resume_on_disk(s):
|
||||
# workspace-SCOPED check only — per-row own id, never a global identity (P0-C)
|
||||
name = s.get('name', '')
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if name.endswith('-creator-claude'):
|
||||
u = s.get('claude_session_id_own')
|
||||
if u:
|
||||
key = cwd.replace('/', '-').replace('_', '-')
|
||||
return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING'
|
||||
key = cwd.replace('/', '-').replace('_', '-')
|
||||
return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no'
|
||||
if name.endswith('-creator-agy'):
|
||||
u = s.get('agy_conversation_id_own')
|
||||
if u:
|
||||
return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING'
|
||||
return 'no'
|
||||
return '?'
|
||||
|
||||
|
||||
def get_job_status(s):
|
||||
jid = s.get('delegate_job_id')
|
||||
if not jid:
|
||||
return ('-', '-')
|
||||
|
||||
project_root = os.environ.get('PROJECT_ROOT', '.')
|
||||
candidates = [
|
||||
os.path.join('.mam', 'jobs', f"{jid}.json"),
|
||||
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
|
||||
os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path) as jf:
|
||||
job_data = json.load(jf)
|
||||
return (jid, job_data.get('status', 'unknown'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (jid, 'unknown')
|
||||
|
||||
|
||||
sessions_detail = []
|
||||
for s in d.get('tmux_sessions', []):
|
||||
name = s.get('name', '?')
|
||||
server = s.get('tmux_server') or 'default'
|
||||
jid, jstatus = get_job_status(s)
|
||||
pane = s.get('pane') or {}
|
||||
sessions_detail.append({
|
||||
# Fields named/typed to match the reviewed D8 contract
|
||||
# (.mam/jobs/40bdce88/claude-reports/report-final.md §3.1) exactly —
|
||||
# mam_core maps this straight onto its Session/Pane/Drift models.
|
||||
'name': name,
|
||||
'server': server,
|
||||
'status': s.get('status', '?'),
|
||||
'tmux_alive': f"{name}|{server}" in alive,
|
||||
'cmd': pane.get('cmd'),
|
||||
'role': s.get('role'),
|
||||
'resume_state': resume_on_disk(s),
|
||||
'job_id': jid,
|
||||
'job_status': jstatus,
|
||||
'pane_cwd': pane.get('cwd'),
|
||||
'attach_command': s.get('attach_command'),
|
||||
'drift_classes': drift_by_name.get(name, []),
|
||||
# Additive beyond the D8 example — needed by the M1 Detail Pane
|
||||
# (pane pid / full launch command / start command / last banner text).
|
||||
'pane_pid': pane.get('pid'),
|
||||
'cmd_full': pane.get('cmd_full'),
|
||||
'start_command': s.get('start_command'),
|
||||
'last_visible_status': s.get('last_visible_status'),
|
||||
})
|
||||
|
||||
drift['sessions_detail'] = sessions_detail
|
||||
print(json.dumps(drift, ensure_ascii=False))
|
||||
PYEOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
|
||||
import os, sys, json, glob
|
||||
|
||||
|
||||
@@ -1,121 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import 'src/providers/session_providers.dart';
|
||||
import 'src/theme/app_theme.dart';
|
||||
import 'src/widgets/detail_pane.dart';
|
||||
import 'src/widgets/session_table.dart';
|
||||
import 'src/widgets/stale_banner.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
runApp(const ProviderScope(child: MamDesktopApp()));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
class MamDesktopApp extends StatelessWidget {
|
||||
const MamDesktopApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
title: 'multi-agent-mux',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
home: const DashboardScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pollAsync = ref.watch(sessionsPollProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(gradient: backgroundGradient),
|
||||
child: SafeArea(
|
||||
child: pollAsync.when(
|
||||
data: (poll) => _DashboardBody(poll: poll),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stackTrace) => _ErrorScreen(error: error),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
class _ErrorScreen extends StatelessWidget {
|
||||
final Object error;
|
||||
const _ErrorScreen({required this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
const Icon(Icons.error_outline, color: AppColors.danger, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
'Failed to start status polling:\n$error',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardBody extends ConsumerWidget {
|
||||
final SessionsPoll poll;
|
||||
const _DashboardBody({required this.poll});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedName = ref.watch(selectedSessionNameProvider);
|
||||
final sessions = poll.snapshot?.sessions ?? const <SessionRow>[];
|
||||
final matches = sessions.where((s) => s.name == selectedName);
|
||||
final selectedRow = matches.isEmpty ? null : matches.first;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_TopBar(poll: poll),
|
||||
StaleBanner(poll: poll),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SessionTable(
|
||||
sessions: sessions,
|
||||
selectedName: selectedName,
|
||||
onSelect: (name) =>
|
||||
ref.read(selectedSessionNameProvider.notifier).state =
|
||||
name,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 380, child: DetailPane(session: selectedRow)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
final SessionsPoll poll;
|
||||
const _TopBar({required this.poll});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = poll.snapshot?.sessions.length ?? 0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'multi-agent-mux',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'$count session${count == 1 ? '' : 's'}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../status_script_locator.dart';
|
||||
|
||||
/// The single [SessionService] instance for this app run — owns the
|
||||
/// resolved path to `status.sh`.
|
||||
final sessionServiceProvider = Provider<SessionService>((ref) {
|
||||
return SessionService(statusScriptPath: locateStatusScript());
|
||||
});
|
||||
|
||||
/// Polling + stale/backoff (D6) sits in `mam_core`; this provider just wires
|
||||
/// it up. `apps/mam_desktop` does not reimplement the timer/backoff logic.
|
||||
final statusRepositoryProvider = Provider<StatusRepository>((ref) {
|
||||
return StatusRepository(sessionService: ref.watch(sessionServiceProvider));
|
||||
});
|
||||
|
||||
/// Reactive stream of [SessionsPoll] the UI subscribes to. 4s base interval
|
||||
/// on success, backing off to 6s/15s on repeated `status.sh` failures.
|
||||
final sessionsPollProvider = StreamProvider<SessionsPoll>((ref) {
|
||||
final repo = ref.watch(statusRepositoryProvider);
|
||||
return repo.watch();
|
||||
});
|
||||
|
||||
/// Currently selected row name for the Master-Detail layout.
|
||||
final selectedSessionNameProvider = StateProvider<String?>((ref) => null);
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Locates `status.sh` by walking up from the current working directory to
|
||||
/// the repo root (marked by `.git`), then descending a fixed path — robust
|
||||
/// regardless of which subdirectory `flutter run` happens to be invoked
|
||||
/// from, unlike a hardcoded relative path.
|
||||
String locateStatusScript() {
|
||||
var dir = Directory.current;
|
||||
while (!Directory('${dir.path}/.git').existsSync()) {
|
||||
final parent = dir.parent;
|
||||
if (parent.path == dir.path) {
|
||||
throw StateError(
|
||||
'Could not locate the repo root (.git) from ${Directory.current.path}. '
|
||||
'Run mam_desktop from within the multi-agent-mux repo checkout.',
|
||||
);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
final scriptPath =
|
||||
'${dir.path}/.agents/skills/multi-agent-mux-status/scripts/status.sh';
|
||||
if (!File(scriptPath).existsSync()) {
|
||||
throw StateError('status.sh not found at $scriptPath');
|
||||
}
|
||||
return scriptPath;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
static const bgTop = Color(0xFF0B0F1A);
|
||||
static const bgBottom = Color(0xFF141B2E);
|
||||
static const accent = Color(0xFF7C5CFF);
|
||||
static const accent2 = Color(0xFF19C6E0);
|
||||
static const surface = Color(0xFF171E31);
|
||||
static const surfaceAlt = Color(0xFF1E2740);
|
||||
static const border = Color(0xFF2A3350);
|
||||
static const textPrimary = Color(0xFFEAF0FF);
|
||||
static const textSecondary = Color(0xFF93A0C4);
|
||||
static const danger = Color(0xFFFF5C7A);
|
||||
static const warning = Color(0xFFFFB454);
|
||||
static const success = Color(0xFF4CE0B3);
|
||||
}
|
||||
|
||||
const backgroundGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.bgTop, AppColors.bgBottom],
|
||||
);
|
||||
|
||||
ThemeData buildAppTheme() {
|
||||
final base = ThemeData(brightness: Brightness.dark, useMaterial3: true);
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.bgTop,
|
||||
colorScheme: base.colorScheme.copyWith(
|
||||
primary: AppColors.accent,
|
||||
secondary: AppColors.accent2,
|
||||
surface: AppColors.surface,
|
||||
error: AppColors.danger,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
bodyColor: AppColors.textPrimary,
|
||||
displayColor: AppColors.textPrimary,
|
||||
),
|
||||
dividerColor: AppColors.border,
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class _KV {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool copyable;
|
||||
const _KV(this.label, this.value, {this.copyable = false});
|
||||
}
|
||||
|
||||
class DetailPane extends StatelessWidget {
|
||||
final SessionRow? session;
|
||||
const DetailPane({super.key, required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = session;
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.surface, AppColors.surfaceAlt],
|
||||
),
|
||||
border: Border(left: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: s == null ? const _EmptyDetail() : _DetailContent(session: s),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyDetail extends StatelessWidget {
|
||||
const _EmptyDetail();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Select a session to see details',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailContent extends StatelessWidget {
|
||||
final SessionRow session;
|
||||
const _DetailContent({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = session;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Header(session: s),
|
||||
const SizedBox(height: 24),
|
||||
_Section(
|
||||
title: 'PANE',
|
||||
entries: [
|
||||
_KV('pid', s.pane.pid?.toString() ?? '?'),
|
||||
_KV('cwd', s.pane.cwd ?? '?'),
|
||||
_KV('cmd', s.pane.cmd ?? '?'),
|
||||
_KV('cmd_full', s.pane.cmdFull ?? '?', copyable: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'ATTACH',
|
||||
entries: [
|
||||
_KV('attach_command', s.attachCommand ?? '?', copyable: true),
|
||||
_KV('start_command', s.startCommand ?? '?', copyable: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Section(
|
||||
title: 'STATUS',
|
||||
entries: [
|
||||
_KV('last_visible_status', s.lastVisibleStatus ?? '?'),
|
||||
_KV('resume_state', s.resumeState),
|
||||
_KV('job_id', s.jobId),
|
||||
_KV('job_status', s.jobStatus),
|
||||
_KV(
|
||||
'drift_classes',
|
||||
s.driftClasses.isEmpty ? '-' : s.driftClasses.join(', '),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final SessionRow session;
|
||||
const _Header({required this.session});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = session;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.accent, AppColors.accent2],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.accent.withValues(alpha: 0.35),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
s.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Pill(text: s.status.toUpperCase()),
|
||||
_Pill(text: s.tmuxAlive ? 'TMUX ALIVE' : 'TMUX DEAD'),
|
||||
if (s.role != null) _Pill(text: s.role!.toUpperCase()),
|
||||
_Pill(text: 'SERVER: ${s.server}'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
final String text;
|
||||
const _Pill({required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<_KV> entries;
|
||||
const _Section({required this.title, required this.entries});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...entries.map((e) => _KVRow(entry: e)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KVRow extends StatelessWidget {
|
||||
final _KV entry;
|
||||
const _KVRow({required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(
|
||||
entry.label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
entry.value,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry.copyable)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(
|
||||
Icons.copy,
|
||||
size: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: entry.value)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import 'package:data_table_2/data_table_2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class SessionTable extends StatelessWidget {
|
||||
final List<SessionRow> sessions;
|
||||
final String? selectedName;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
const SessionTable({
|
||||
super.key,
|
||||
required this.sessions,
|
||||
required this.selectedName,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: DataTable2(
|
||||
columnSpacing: 16,
|
||||
horizontalMargin: 16,
|
||||
minWidth: 900,
|
||||
headingRowColor: WidgetStateProperty.all(AppColors.surfaceAlt),
|
||||
headingTextStyle: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
dataRowHeight: 44,
|
||||
empty: const Center(
|
||||
child: Text(
|
||||
'(no sessions registered)',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
columns: const [
|
||||
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
|
||||
DataColumn2(label: Text('SERVER'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('YAML'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('TMUX'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('CMD'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('RESUME'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('JOB_ID'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('JOB_STATUS'), size: ColumnSize.S),
|
||||
DataColumn2(label: Text('DRIFT'), size: ColumnSize.S),
|
||||
],
|
||||
rows: sessions.map((s) {
|
||||
final selected = s.name == selectedName;
|
||||
return DataRow2(
|
||||
selected: selected,
|
||||
color: selected
|
||||
? WidgetStateProperty.all(
|
||||
AppColors.accent.withValues(alpha: 0.16),
|
||||
)
|
||||
: null,
|
||||
onTap: () => onSelect(s.name),
|
||||
cells: [
|
||||
DataCell(
|
||||
Text(
|
||||
s.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.server,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(_StatusChip(status: s.status)),
|
||||
DataCell(_TmuxChip(alive: s.tmuxAlive)),
|
||||
DataCell(
|
||||
Text(
|
||||
s.pane.cmd ?? '?',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.resumeState,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.jobId,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
s.jobStatus,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
s.hasDrift
|
||||
? Text(
|
||||
s.driftClasses.join(','),
|
||||
style: const TextStyle(
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'-',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color color;
|
||||
switch (status) {
|
||||
case 'running':
|
||||
color = AppColors.success;
|
||||
case 'stopped':
|
||||
case 'terminated':
|
||||
case 'archived':
|
||||
color = AppColors.textSecondary;
|
||||
default:
|
||||
color = AppColors.warning;
|
||||
}
|
||||
return Text(
|
||||
status,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TmuxChip extends StatelessWidget {
|
||||
final bool alive;
|
||||
const _TmuxChip({required this.alive});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
alive ? 'alive' : 'dead',
|
||||
style: TextStyle(
|
||||
color: alive ? AppColors.success : AppColors.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// D6: when `status.sh` polling fails, the last good snapshot is kept and
|
||||
/// this banner surfaces staleness instead of the dashboard silently
|
||||
/// blanking out or crashing.
|
||||
class StaleBanner extends StatelessWidget {
|
||||
final SessionsPoll poll;
|
||||
const StaleBanner({super.key, required this.poll});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!poll.stale) return const SizedBox.shrink();
|
||||
|
||||
final lastOk = poll.lastOkAt;
|
||||
final lastOkText = lastOk == null
|
||||
? '?'
|
||||
: '${lastOk.hour.toString().padLeft(2, '0')}:'
|
||||
'${lastOk.minute.toString().padLeft(2, '0')}:'
|
||||
'${lastOk.second.toString().padLeft(2, '0')}';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: AppColors.warning.withValues(alpha: 0.15),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: AppColors.warning,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'⚠ status snapshot stale (last ok: $lastOkText)',
|
||||
style: const TextStyle(
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,74 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mam_core/mam_core.dart';
|
||||
|
||||
import 'package:mam_desktop/main.dart';
|
||||
import 'package:mam_desktop/src/providers/session_providers.dart';
|
||||
import 'package:mam_desktop/src/theme/app_theme.dart';
|
||||
import 'package:mam_desktop/src/widgets/detail_pane.dart';
|
||||
import 'package:mam_desktop/src/widgets/session_table.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('SessionTable renders rows and drives selection', (tester) async {
|
||||
final rows = [
|
||||
SessionRow(
|
||||
name: 'demo-session',
|
||||
server: 'default',
|
||||
status: 'running',
|
||||
tmuxAlive: true,
|
||||
pane: const Pane(
|
||||
pid: 123,
|
||||
cwd: '/x',
|
||||
cmd: 'claude',
|
||||
cmdFull: 'claude --foo',
|
||||
),
|
||||
role: 'creator',
|
||||
resumeState: 'yes',
|
||||
jobId: '-',
|
||||
jobStatus: '-',
|
||||
attachCommand: 'tmux attach -t demo-session',
|
||||
driftClasses: const ['B'],
|
||||
),
|
||||
];
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
String? selected;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: Scaffold(
|
||||
body: SessionTable(
|
||||
sessions: rows,
|
||||
selectedName: null,
|
||||
onSelect: (name) => selected = name,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
expect(find.text('demo-session'), findsOneWidget);
|
||||
expect(find.text('running'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('demo-session'));
|
||||
await tester.pump();
|
||||
expect(selected, 'demo-session');
|
||||
});
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
testWidgets('DetailPane shows placeholder when nothing selected', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: const Scaffold(body: DetailPane(session: null)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Select a session to see details'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('selectedSessionNameProvider defaults to null', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
expect(container.read(selectedSessionNameProvider), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
+284
-284
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+51
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
@@ -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)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user