feat: add optional runtime file logging config

This commit is contained in:
Ashley Venn 2026-07-02 18:08:36 -07:00
parent e20863108e
commit d8f3368c3d
7 changed files with 516 additions and 5 deletions

View file

@ -33,6 +33,34 @@ It is not inserted during normal startup.
Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`. Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
Scheduler core remains the source of truth for task state and Today ordering. 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 ## Package Boundary
Allowed app imports include public scheduler APIs such as: Allowed app imports include public scheduler APIs such as:

View file

@ -12,6 +12,7 @@ import 'package:scheduler_persistence_sqlite/sqlite.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/selected_date_controller.dart'; import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import 'runtime/focus_flow_file_logger.dart';
import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel; import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
import 'scheduler_app_composition.dart'; import 'scheduler_app_composition.dart';
@ -25,6 +26,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
required this.commandUseCases, required this.commandUseCases,
required this.date, required this.date,
required this.readAt, required this.readAt,
required this.logger,
}); });
/// Opens the configured SQLite runtime and bootstraps required owner state. /// Opens the configured SQLite runtime and bootstraps required owner state.
@ -32,20 +34,32 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
String? sqlitePath, String? sqlitePath,
Clock? clock, Clock? clock,
CivilDate? selectedDate, CivilDate? selectedDate,
FocusFlowFileLogger? logger,
}) async { }) async {
final resolvedLogger = logger ?? FocusFlowFileLogger.disabled();
final resolvedSqlitePath = sqlitePath ?? sqlitePathFromEnvironment();
await resolvedLogger.info('Opening SQLite runtime at $resolvedSqlitePath.');
final runtime = await SqliteApplicationRuntime.open( final runtime = await SqliteApplicationRuntime.open(
path: sqlitePath ?? sqlitePathFromEnvironment(), path: resolvedSqlitePath,
); );
final bootstrap = await runtime.bootstrap(); final bootstrap = await runtime.bootstrap();
if (bootstrap.isFailure) { if (bootstrap.isFailure) {
await runtime.close(); await runtime.close();
await resolvedLogger.error(
'SQLite bootstrap failed.',
error: bootstrap.failure!.code.name,
);
throw StateError( throw StateError(
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}', 'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
); );
} }
await resolvedLogger.debug('SQLite bootstrap completed.');
final resolvedClock = clock ?? const SystemClock(); final resolvedClock = clock ?? const SystemClock();
final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now()); final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now());
final readAt = resolvedClock.now().toUtc(); final readAt = resolvedClock.now().toUtc();
await resolvedLogger.info(
'Persistent scheduler composition opened for ${date.toIsoString()}.',
);
return PersistentSchedulerComposition._( return PersistentSchedulerComposition._(
runtime: runtime, runtime: runtime,
@ -62,6 +76,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
), ),
date: date, date: date,
readAt: readAt, readAt: readAt,
logger: resolvedLogger,
); );
} }
@ -98,6 +113,9 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
/// Stable read clock instant for operation contexts. /// Stable read clock instant for operation contexts.
final DateTime readAt; final DateTime readAt;
/// Optional app runtime logger.
final FocusFlowFileLogger logger;
/// Pending close operation, if disposal has started. /// Pending close operation, if disposal has started.
Future<void>? _disposeFuture; Future<void>? _disposeFuture;
@ -177,6 +195,13 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
/// Closes the persistent runtime. /// Closes the persistent runtime.
@override @override
Future<void> dispose() { Future<void> dispose() {
return _disposeFuture ??= runtime.close(); return _disposeFuture ??= _dispose();
}
/// Closes runtime resources and records shutdown diagnostics when enabled.
Future<void> _dispose() async {
await logger.debug('Closing SQLite runtime.');
await runtime.close();
await logger.info('SQLite runtime closed.');
} }
} }

View file

@ -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<FocusFlowFileLogger> 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<void> trace(String message) {
return write(FocusFlowLogLevel.trace, message);
}
/// Writes a debug-level [message] when enabled for debug or lower.
Future<void> debug(String message) {
return write(FocusFlowLogLevel.debug, message);
}
/// Writes an info-level [message] when enabled for info or lower.
Future<void> info(String message) {
return write(FocusFlowLogLevel.info, message);
}
/// Writes a warning-level [message] when enabled for warning or lower.
Future<void> 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<void> 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<void> 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';
}

View file

@ -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<FocusFlowRuntimeConfig> 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<String, Object?>) {
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<String, Object?> 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';
}

View file

@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
import 'app/focus_flow_app.dart'; import 'app/focus_flow_app.dart';
import 'app/persistent_scheduler_composition.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/demo_scheduler_composition.dart';
export 'app/focus_flow_app.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. /// Starts the FocusFlow app with the persistent SQLite scheduler composition.
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
var logger = FocusFlowFileLogger.disabled();
try { 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)); 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())); runApp(_StartupFailureApp(message: error.toString()));
} }
} }

View file

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

View file

@ -77,7 +77,8 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
), ),
if (relativePath != 'test/forbidden_imports_test.dart' && if (relativePath != 'test/forbidden_imports_test.dart' &&
relativePath != 'test/persistent_composition_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'), const _ForbiddenImport("dart:io", 'dart:io'),
]; ];
} }
@ -87,6 +88,12 @@ bool _isPersistentComposition(String relativePath) {
return relativePath == 'lib/app/persistent_scheduler_composition.dart'; 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. /// 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. /// 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 { class _ForbiddenImport {