// Central error log for Studio. Every error surfaced to the // operator (SnackBar, error sheet, recoverable dialog) is also // appended here so `fai doctor` and "Open log" can show what // went wrong without the operator having to reproduce the // failure. // // Path: `~/.fai/logs/studio-errors.log` (cross-platform via // HOME / USERPROFILE). One line per event, JSON-shaped so // `fai doctor` can parse it; humans can still read it because // the JSON is single-line and short. // // Best-effort by design: a failed write to the log file must // never break the UI layer that asked us to log. We swallow // IO errors silently — the worst case is the operator loses // the trail of one failure, not the failure itself. // // Rotation: when the file passes 256 KiB we move it to // `studio-errors.log.1` and start fresh. One rotation level is // enough — anything older is noise, and a Studio session // rarely produces more than a few KiB of error text. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; class FaiLog { FaiLog._(); static final FaiLog instance = FaiLog._(); // Serialise concurrent writes — Future-based mutex so a burst // of errors (rare but happens during failed install retries) // doesn't interleave half-lines on disk. Future _tail = Future.value(); static const int _rotateBytes = 256 * 1024; /// Test seam. Set this in `setUp` to redirect the singleton at /// a per-test temp file; reset to `null` in `tearDown`. Production /// code never touches it — the default getter computes from HOME. @visibleForTesting static String? testPathOverride; String get _path { final override = testPathOverride; if (override != null) return override; final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; return p.join(home, '.fai', 'logs', 'studio-errors.log'); } /// Absolute path of the studio-errors log. Public so the /// Doctor page and the in-app log viewer can read it without /// hard-coding the layout twice. String get path => _path; /// Append a single error event. The [source] is a short tag /// (`flows.run`, `daemon.logs`, …) that lets a reader group /// related failures. [error] can be any thrown object — gRPC /// codes, plain strings, exceptions; we serialise it via /// [error.toString()] so the on-disk record matches what the /// operator saw on screen. Future error(String source, Object error, {Object? context}) { final entry = { 'ts': DateTime.now().toUtc().toIso8601String(), 'level': 'error', 'source': source, 'error': error.toString(), if (context != null) 'context': context.toString(), }; return _append(entry); } /// Read the last [maxLines] lines back. Used by `fai doctor` /// + Settings → "Recent errors" view. Returns oldest first so /// the caller can render in chronological order. Future> tail({int maxLines = 50}) async { try { final f = File(_path); if (!await f.exists()) return const []; final raw = await f.readAsString(); final lines = raw.split('\n').where((l) => l.isNotEmpty).toList(); if (lines.length <= maxLines) return lines; return lines.sublist(lines.length - maxLines); } catch (_) { return const []; } } Future _append(Map entry) { final next = _tail.then((_) async { try { final f = File(_path); final dir = Directory(p.dirname(_path)); if (!await dir.exists()) { await dir.create(recursive: true); } // Rotate before write so a hot-loop never balloons past // the cap. Single-level rotation: keep the previous file // as `.1`, anything older drops on the floor. if (await f.exists()) { final size = await f.length(); if (size >= _rotateBytes) { final rotated = File('$_path.1'); if (await rotated.exists()) { await rotated.delete(); } await f.rename(rotated.path); } } await f.writeAsString( '${jsonEncode(entry)}\n', mode: FileMode.append, flush: false, ); } catch (_) { // Best-effort. A failed log write must not crash the UI. } }); _tail = next.catchError((_) {}); return next; } }