From d8f3368c3d391d7d9d24bdb3a5905dd66329b2ef Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 2 Jul 2026 18:08:36 -0700 Subject: [PATCH] feat: add optional runtime file logging config --- apps/focus_flow_flutter/README.md | 28 +++ .../app/persistent_scheduler_composition.dart | 29 ++- .../app/runtime/focus_flow_file_logger.dart | 165 ++++++++++++++++++ .../runtime/focus_flow_runtime_config.dart | 158 +++++++++++++++++ apps/focus_flow_flutter/lib/main.dart | 21 ++- .../test/app/runtime_config_test.dart | 111 ++++++++++++ .../test/forbidden_imports_test.dart | 9 +- 7 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart create mode 100644 apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart create mode 100644 apps/focus_flow_flutter/test/app/runtime_config_test.dart diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index 89ab14d..a4676a1 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -33,6 +33,34 @@ It is not inserted during normal startup. Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`. Scheduler core remains the source of truth for task state and Today ordering. +## Runtime Config + +The app optionally reads a JSON config file from: + +```text +~/ADHD_Scheduler/config.json +``` + +Override the config path with: + +```sh +flutter run -d linux --dart-define=FOCUS_FLOW_CONFIG_PATH=/tmp/focus_flow_config.json +``` + +Supported optional keys: + +```json +{ + "LogLevel": "debug", + "LogfileLocation": "~/ADHD_Scheduler/debug/" +} +``` + +`LogLevel` accepts `trace`, `debug`, `info`, `warn`, or `error`. +`LogfileLocation` is treated as a directory, and the app writes +`focus_flow.log` inside it. If either key is absent or invalid, file logging is +not enabled and the app ignores that setting. + ## Package Boundary Allowed app imports include public scheduler APIs such as: diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index a4a5343..3fa119c 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -12,6 +12,7 @@ import 'package:scheduler_persistence_sqlite/sqlite.dart'; import '../controllers/scheduler_command_controller.dart'; import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; +import 'runtime/focus_flow_file_logger.dart'; import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel; import 'scheduler_app_composition.dart'; @@ -25,6 +26,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { required this.commandUseCases, required this.date, required this.readAt, + required this.logger, }); /// Opens the configured SQLite runtime and bootstraps required owner state. @@ -32,20 +34,32 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { String? sqlitePath, Clock? clock, CivilDate? selectedDate, + FocusFlowFileLogger? logger, }) async { + final resolvedLogger = logger ?? FocusFlowFileLogger.disabled(); + final resolvedSqlitePath = sqlitePath ?? sqlitePathFromEnvironment(); + await resolvedLogger.info('Opening SQLite runtime at $resolvedSqlitePath.'); final runtime = await SqliteApplicationRuntime.open( - path: sqlitePath ?? sqlitePathFromEnvironment(), + path: resolvedSqlitePath, ); final bootstrap = await runtime.bootstrap(); if (bootstrap.isFailure) { await runtime.close(); + await resolvedLogger.error( + 'SQLite bootstrap failed.', + error: bootstrap.failure!.code.name, + ); throw StateError( 'SQLite bootstrap failed: ${bootstrap.failure!.code.name}', ); } + await resolvedLogger.debug('SQLite bootstrap completed.'); final resolvedClock = clock ?? const SystemClock(); final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now()); final readAt = resolvedClock.now().toUtc(); + await resolvedLogger.info( + 'Persistent scheduler composition opened for ${date.toIsoString()}.', + ); return PersistentSchedulerComposition._( runtime: runtime, @@ -62,6 +76,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { ), date: date, readAt: readAt, + logger: resolvedLogger, ); } @@ -98,6 +113,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Stable read clock instant for operation contexts. final DateTime readAt; + /// Optional app runtime logger. + final FocusFlowFileLogger logger; + /// Pending close operation, if disposal has started. Future? _disposeFuture; @@ -177,6 +195,13 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { /// Closes the persistent runtime. @override Future dispose() { - return _disposeFuture ??= runtime.close(); + return _disposeFuture ??= _dispose(); + } + + /// Closes runtime resources and records shutdown diagnostics when enabled. + Future _dispose() async { + await logger.debug('Closing SQLite runtime.'); + await runtime.close(); + await logger.info('SQLite runtime closed.'); } } diff --git a/apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart new file mode 100644 index 0000000..dd63d15 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_file_logger.dart @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Provides optional FocusFlow runtime file logging. +library; + +import 'dart:io'; + +import 'focus_flow_runtime_config.dart'; + +/// Minimal append-only file logger for FocusFlow runtime diagnostics. +final class FocusFlowFileLogger { + /// Creates a logger with explicit runtime state. + FocusFlowFileLogger._({ + required this.minimumLevel, + required this.logFilePath, + required this._now, + }); + + /// Creates a disabled logger that drops every message. + factory FocusFlowFileLogger.disabled({DateTime Function()? now}) { + return FocusFlowFileLogger._( + minimumLevel: null, + logFilePath: null, + now: now ?? _utcNow, + ); + } + + /// Opens a logger from [config] when both logging settings are present. + static Future open( + FocusFlowRuntimeConfig config, { + DateTime Function()? now, + String fileName = 'focus_flow.log', + }) async { + final level = config.logLevel; + final location = config.logFileLocation; + if (level == null || location == null) { + return FocusFlowFileLogger.disabled(now: now); + } + + try { + final directory = Directory(location); + await directory.create(recursive: true); + return FocusFlowFileLogger._( + minimumLevel: level, + logFilePath: _joinPath(directory.path, fileName), + now: now ?? _utcNow, + ); + } on Object { + return FocusFlowFileLogger.disabled(now: now); + } + } + + /// Minimum severity that will be written, or null when disabled. + final FocusFlowLogLevel? minimumLevel; + + /// Concrete file path receiving appended log lines. + final String? logFilePath; + + /// Clock boundary used for log timestamps. + final DateTime Function() _now; + + /// Whether this logger currently writes to a file. + bool get isEnabled => minimumLevel != null && logFilePath != null; + + /// Writes a trace-level [message] when enabled for trace. + Future trace(String message) { + return write(FocusFlowLogLevel.trace, message); + } + + /// Writes a debug-level [message] when enabled for debug or lower. + Future debug(String message) { + return write(FocusFlowLogLevel.debug, message); + } + + /// Writes an info-level [message] when enabled for info or lower. + Future info(String message) { + return write(FocusFlowLogLevel.info, message); + } + + /// Writes a warning-level [message] when enabled for warning or lower. + Future warn(String message, {Object? error, StackTrace? stackTrace}) { + return write( + FocusFlowLogLevel.warn, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes an error-level [message] when file logging is enabled. + Future error(String message, {Object? error, StackTrace? stackTrace}) { + return write( + FocusFlowLogLevel.error, + message, + error: error, + stackTrace: stackTrace, + ); + } + + /// Writes [message] at [level] when it passes the configured threshold. + Future write( + FocusFlowLogLevel level, + String message, { + Object? error, + StackTrace? stackTrace, + }) async { + final path = logFilePath; + final configuredLevel = minimumLevel; + if (path == null || + configuredLevel == null || + !_shouldWrite(level, configuredLevel)) { + return; + } + + final buffer = StringBuffer() + ..write(_now().toUtc().toIso8601String()) + ..write(' ') + ..write(level.name.toUpperCase()) + ..write(' ') + ..write(message.trim()); + if (error != null) { + buffer + ..write(' | error=') + ..write(error); + } + if (stackTrace != null) { + buffer + ..write(' | stackTrace=') + ..write(stackTrace.toString().replaceAll('\n', r'\n')); + } + buffer.writeln(); + + await File( + path, + ).writeAsString(buffer.toString(), mode: FileMode.append, flush: true); + } +} + +/// Returns whether [level] should be written for [minimumLevel]. +bool _shouldWrite(FocusFlowLogLevel level, FocusFlowLogLevel minimumLevel) { + return _severity(level) >= _severity(minimumLevel); +} + +/// Converts [level] to an ordered severity value. +int _severity(FocusFlowLogLevel level) { + return switch (level) { + FocusFlowLogLevel.trace => 0, + FocusFlowLogLevel.debug => 1, + FocusFlowLogLevel.info => 2, + FocusFlowLogLevel.warn => 3, + FocusFlowLogLevel.error => 4, + }; +} + +/// Returns the current UTC timestamp. +DateTime _utcNow() => DateTime.now().toUtc(); + +/// Joins [base] and [child] with the platform path separator. +String _joinPath(String base, String child) { + if (base.endsWith('/') || base.endsWith(r'\')) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart new file mode 100644 index 0000000..1d68d35 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/runtime/focus_flow_runtime_config.dart @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Loads optional FocusFlow runtime configuration from a local JSON file. +library; + +import 'dart:convert'; +import 'dart:io'; + +/// File-backed runtime configuration for optional developer diagnostics. +final class FocusFlowRuntimeConfig { + /// Creates a runtime configuration with optional logging controls. + const FocusFlowRuntimeConfig({this.logLevel, this.logFileLocation}); + + /// Loads runtime configuration from [path] or the configured default path. + static Future load({String? path}) async { + final configPath = path ?? configPathFromEnvironment(); + final file = File(_expandHome(configPath)); + if (!await file.exists()) { + return const FocusFlowRuntimeConfig(); + } + + try { + final decoded = jsonDecode(await file.readAsString()); + if (decoded is Map) { + return fromJson(decoded); + } + } on Object { + return const FocusFlowRuntimeConfig(); + } + + return const FocusFlowRuntimeConfig(); + } + + /// Resolves the config file path from a Dart define or the local default. + static String configPathFromEnvironment() { + const configured = String.fromEnvironment('FOCUS_FLOW_CONFIG_PATH'); + final trimmed = configured.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + return defaultConfigPath(); + } + + /// Returns the default config file path. + static String defaultConfigPath() { + return _joinPath(_schedulerHomePath(), 'config.json'); + } + + /// Builds a runtime configuration from decoded JSON fields. + static FocusFlowRuntimeConfig fromJson(Map json) { + final level = FocusFlowLogLevel.tryParse(_optionalString(json['LogLevel'])); + final logLocation = _optionalString(json['LogfileLocation']); + return FocusFlowRuntimeConfig( + logLevel: level, + logFileLocation: logLocation == null ? null : _expandHome(logLocation), + ); + } + + /// Optional minimum level for file logging. + final FocusFlowLogLevel? logLevel; + + /// Optional directory where the app should append its debug log file. + final String? logFileLocation; + + /// Whether this configuration has enough settings to enable file logging. + bool get enablesFileLogging => logLevel != null && logFileLocation != null; +} + +/// Supported FocusFlow file logging levels. +enum FocusFlowLogLevel { + /// Logs every message, including trace-level detail. + trace, + + /// Logs debug, info, warning, and error messages. + debug, + + /// Logs normal operational information, warnings, and errors. + info, + + /// Logs warnings and errors. + warn, + + /// Logs only errors. + error; + + /// Parses [value] into a supported logging level. + static FocusFlowLogLevel? tryParse(String? value) { + final normalized = value?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + for (final level in FocusFlowLogLevel.values) { + if (level.name == normalized) { + return level; + } + } + return null; + } +} + +/// Reads [value] as a non-empty string when present. +String? _optionalString(Object? value) { + if (value is! String) { + return null; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +/// Returns the local scheduler home directory path. +String _schedulerHomePath() { + final configured = Platform.environment['SCHEDULER_HOME']?.trim(); + if (configured != null && configured.isNotEmpty) { + return _expandHome(configured); + } + final home = _homePath(); + if (home == null) { + return 'ADHD_Scheduler'; + } + return _joinPath(home, 'ADHD_Scheduler'); +} + +/// Resolves the current user's home directory when the platform exposes it. +String? _homePath() { + final home = Platform.environment['HOME']?.trim(); + if (home != null && home.isNotEmpty) { + return home; + } + final userProfile = Platform.environment['USERPROFILE']?.trim(); + if (userProfile != null && userProfile.isNotEmpty) { + return userProfile; + } + return null; +} + +/// Expands a leading `~` in [path] using the platform home directory. +String _expandHome(String path) { + if (path == '~') { + return _homePath() ?? path; + } + if (path.startsWith('~/') || path.startsWith(r'~\')) { + final home = _homePath(); + if (home == null) { + return path; + } + return _joinPath(home, path.substring(2)); + } + return path; +} + +/// Joins [base] and [child] with the platform path separator. +String _joinPath(String base, String child) { + if (base.endsWith('/') || base.endsWith(r'\')) { + return '$base$child'; + } + return '$base${Platform.pathSeparator}$child'; +} diff --git a/apps/focus_flow_flutter/lib/main.dart b/apps/focus_flow_flutter/lib/main.dart index 31f44a3..6a49531 100644 --- a/apps/focus_flow_flutter/lib/main.dart +++ b/apps/focus_flow_flutter/lib/main.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import 'app/focus_flow_app.dart'; import 'app/persistent_scheduler_composition.dart'; +import 'app/runtime/focus_flow_file_logger.dart'; +import 'app/runtime/focus_flow_runtime_config.dart'; export 'app/demo_scheduler_composition.dart'; export 'app/focus_flow_app.dart'; @@ -16,10 +18,25 @@ export 'app/persistent_scheduler_composition.dart'; /// Starts the FocusFlow app with the persistent SQLite scheduler composition. Future main() async { WidgetsFlutterBinding.ensureInitialized(); + var logger = FocusFlowFileLogger.disabled(); try { - final composition = await PersistentSchedulerComposition.open(); + final config = await FocusFlowRuntimeConfig.load(); + logger = await FocusFlowFileLogger.open(config); + await logger.info('FocusFlow startup started.'); + await logger.debug( + 'Runtime config loaded. fileLogging=${logger.isEnabled}', + ); + final composition = await PersistentSchedulerComposition.open( + logger: logger, + ); + await logger.info('FocusFlow startup completed.'); runApp(FocusFlowApp(composition: composition)); - } on Object catch (error) { + } on Object catch (error, stackTrace) { + await logger.error( + 'FocusFlow startup failed.', + error: error, + stackTrace: stackTrace, + ); runApp(_StartupFailureApp(message: error.toString())); } } diff --git a/apps/focus_flow_flutter/test/app/runtime_config_test.dart b/apps/focus_flow_flutter/test/app/runtime_config_test.dart new file mode 100644 index 0000000..e9a38cf --- /dev/null +++ b/apps/focus_flow_flutter/test/app/runtime_config_test.dart @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests FocusFlow runtime config and file logging behavior. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_flow_flutter/app/runtime/focus_flow_file_logger.dart'; +import 'package:focus_flow_flutter/app/runtime/focus_flow_runtime_config.dart'; + +/// Runs runtime config tests. +void main() { + group('FocusFlowRuntimeConfig', () { + test('missing config file leaves optional settings unset', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-config-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + + final config = await FocusFlowRuntimeConfig.load( + path: '${directory.path}${Platform.pathSeparator}missing.json', + ); + + expect(config.logLevel, isNull); + expect(config.logFileLocation, isNull); + expect(config.enablesFileLogging, isFalse); + }); + + test('loads supported settings from a config file', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-config-file-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final configFile = File( + '${directory.path}${Platform.pathSeparator}config.json', + ); + await configFile.writeAsString( + jsonEncode({'LogLevel': 'trace', 'LogfileLocation': directory.path}), + ); + + final config = await FocusFlowRuntimeConfig.load(path: configFile.path); + + expect(config.logLevel, FocusFlowLogLevel.trace); + expect(config.logFileLocation, directory.path); + expect(config.enablesFileLogging, isTrue); + }); + + test('loads supported logging settings from JSON', () { + final config = FocusFlowRuntimeConfig.fromJson({ + 'LogLevel': 'debug', + 'LogfileLocation': '/tmp/focus-flow-logs', + }); + + expect(config.logLevel, FocusFlowLogLevel.debug); + expect(config.logFileLocation, '/tmp/focus-flow-logs'); + expect(config.enablesFileLogging, isTrue); + }); + + test('ignores missing and unsupported optional settings', () { + final config = FocusFlowRuntimeConfig.fromJson({ + 'LogLevel': 'verbose', + 'LogfileLocation': ' ', + }); + + expect(config.logLevel, isNull); + expect(config.logFileLocation, isNull); + expect(config.enablesFileLogging, isFalse); + }); + }); + + group('FocusFlowFileLogger', () { + test('does nothing unless both logging settings are set', () async { + final logger = await FocusFlowFileLogger.open( + const FocusFlowRuntimeConfig(logLevel: FocusFlowLogLevel.debug), + ); + + expect(logger.isEnabled, isFalse); + await logger.info('ignored'); + }); + + test('writes messages at or above the configured level', () async { + final directory = await Directory.systemTemp.createTemp( + 'focus-flow-log-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + final logger = await FocusFlowFileLogger.open( + FocusFlowRuntimeConfig( + logLevel: FocusFlowLogLevel.warn, + logFileLocation: directory.path, + ), + now: () => DateTime.utc(2026, 7, 3, 12), + ); + + await logger.info('not written'); + await logger.warn('written warning'); + await logger.error('written error', error: 'boom'); + + final logFile = File( + '${directory.path}${Platform.pathSeparator}focus_flow.log', + ); + final contents = await logFile.readAsString(); + expect(contents, isNot(contains('not written'))); + expect(contents, contains('2026-07-03T12:00:00.000Z WARN')); + expect(contents, contains('written warning')); + expect(contents, contains('ERROR written error | error=boom')); + }); + }); +} diff --git a/apps/focus_flow_flutter/test/forbidden_imports_test.dart b/apps/focus_flow_flutter/test/forbidden_imports_test.dart index 3815853..f49f2e1 100644 --- a/apps/focus_flow_flutter/test/forbidden_imports_test.dart +++ b/apps/focus_flow_flutter/test/forbidden_imports_test.dart @@ -77,7 +77,8 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) { ), if (relativePath != 'test/forbidden_imports_test.dart' && relativePath != 'test/persistent_composition_test.dart' && - !_isPersistentComposition(relativePath)) + relativePath != 'test/app/runtime_config_test.dart' && + !_isApprovedRuntimeBoundary(relativePath)) const _ForbiddenImport("dart:io", 'dart:io'), ]; } @@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) { return relativePath == 'lib/app/persistent_scheduler_composition.dart'; } +/// Whether [relativePath] is approved to touch local runtime files directly. +bool _isApprovedRuntimeBoundary(String relativePath) { + return _isPersistentComposition(relativePath) || + relativePath.startsWith('lib/app/runtime/'); +} + /// Private implementation type for `_ForbiddenImport` in this library. /// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. class _ForbiddenImport {