Files
clide/lib/kernel/src/facade.dart
T
jpmschweitzerandClaude Opus 4.8 85cc34e09c feat(log): crash-survivable FileLogSink + dev/prod verbosity toggle (T-432)
First increment of the observability epic (T-425), the productive pivot after
the ConPTY freeze refused to reproduce on CI: if we can't reproduce it, make
the next occurrence leave evidence.

- FileLogSink (lib/kernel/src/file_log_sink.dart): synchronous, crash-survivable
  LogSink. Appends each record as one JSON line to a size-rotated file; fsyncs
  warn/error + risky-source (pty/ffi/conpty/watchdog) records immediately so the
  last breadcrumb is on disk before a hard death, batches the rest on a timer.
  Never throws. Flutter-free → unit-tested under dart test against a temp dir.
- logDirectory() (paths.dart): persistent per-platform log dir (LOCALAPPDATA /
  ~/Library/Logs / $XDG_STATE_HOME) — durable across reboot, unlike the
  ephemeral socketDirectory.
- resolveLogLevel() (log.dart): the requested dev/prod toggle. CLIDE_LOG
  dart-define → CLIDE_LOG env → app.log.level setting → warn(release)/info(debug).
  Lenient parse; an invalid source falls through.
- Boot wiring (facade.boot + main.dart): FileLogSink leads the sink chain (so a
  crash records before the volatile stderr/ring sinks) and the resolved level
  sets Logger.minLevel.

Tests: FileLogSink (JSON shape, error/stack, rotation cap, append-across-restart,
timer-cancel), resolveLogLevel precedence + fall-through, logDirectory per-OS.
Coverage gate 95.11%.

Follow-ups under T-425: live toggle CLI/command/chip (T-433), FFI breadcrumbs
(T-434), watchdog isolate (T-435), CI artifact wiring (T-436).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:29:41 +02:00

326 lines
11 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/text_zoom.dart';
import 'package:clide/kernel/src/theme/theme_persistence.dart';
import 'package:clide/kernel/src/toast.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/filter_state.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/extensions_manager.dart';
import 'package:clide/kernel/src/files.dart';
import 'package:clide/kernel/src/focus.dart';
import 'package:clide/kernel/src/i18n/catalog_loader.dart';
import 'package:clide/kernel/src/i18n/i18n.dart';
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/log_ring.dart';
import 'package:clide/kernel/src/net.dart';
import 'package:clide/kernel/src/notify.dart';
import 'package:clide/kernel/src/os.dart';
import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/quick_open.dart';
import 'package:clide/kernel/src/reader_nav.dart';
import 'package:clide/kernel/src/recent_files.dart';
import 'package:clide/kernel/src/scheduler.dart';
import 'package:clide/kernel/src/secrets.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/loader.dart';
import 'package:clide/kernel/src/toolchain.dart';
import 'package:clide/kernel/src/tray.dart';
import 'package:clide/kernel/src/window_controls.dart';
import 'package:flutter/widgets.dart';
/// Aggregated kernel services. Feature code that runs outside a
/// BuildContext (extensions, background tasks) holds a [KernelServices]
/// ref directly; widget code reaches them via [ClideKernel.of].
class KernelServices {
KernelServices({
required this.log,
required this.settings,
required this.events,
required this.messages,
required this.filterStates,
required this.ipc,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.quickOpen,
required this.recentFiles,
required this.readerNav,
required this.keybindings,
required this.clipboard,
required this.files,
required this.notify,
required this.dialog,
required this.tray,
required this.secrets,
required this.os,
required this.net,
required this.focus,
required this.project,
required this.extensions,
required this.window,
required this.toolchain,
required this.scheduler,
required this.keymap,
required this.textZoom,
required this.toast,
required this.logRing,
});
final Logger log;
final SettingsStore settings;
final DaemonBus events;
final MessageBus messages;
/// Latest filter value per addressable box, fed by `filter.state`
/// messages — backs the observe-half of `clide ui filter` (T-270).
final FilterStateCache filterStates;
final DaemonClient ipc;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final QuickOpenController quickOpen;
final RecentFilesService recentFiles;
final ReaderNavRegistry readerNav;
final KeybindingResolver keybindings;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
final DialogRouter dialog;
final TrayRegistry tray;
final SecretsVault secrets;
final OsBridge os;
final NetworkStatus net;
final FocusTracker focus;
final ProjectManager project;
final ExtensionManager extensions;
final WindowControls window;
final Toolchain toolchain;
final SchedulerService scheduler;
final KeymapService keymap;
final TextZoom textZoom;
final ToastService toast;
/// Bounded retention of [log] records, for the output dock (T-54 / D-87).
final LogRing logRing;
static Future<KernelServices> boot({
required Directory appDir,
required List<ThemeDefinition> bundledThemes,
required CatalogLoader i18nLoader,
List<String> preloadNamespaces = const [],
Locale defaultLocale = const Locale('en', 'US'),
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
String? socketPath,
DaemonClient Function(Logger, DaemonBus, LayoutArrangement, PanelRegistry)? daemonClientFactory,
DaemonClient? isolateClient,
bool autoStartDaemonClient = true,
Toolchain? toolchain,
Future<void> Function(String path)? onProjectOpen,
Future<String?> Function(String path)? onValidateProject,
DaemonBus? sharedBus,
List<LogSink> additionalSinks = const [],
LogLevel? minLogLevel,
}) async {
final logRing = LogRing();
// additionalSinks lead the chain so a crash-survivable sink (FileLogSink,
// T-425) records the tail before the volatile stderr/ring sinks run.
final log = Logger(minLevel: minLogLevel ?? LogLevel.info, sinks: [...additionalSinks, stderrSink, logRing.add]);
final events = sharedBus ?? DaemonBus();
final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages);
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
await settings.load();
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
for (final ns in preloadNamespaces) {
await i18n.ensureNamespaceLoaded(ns);
}
final theme = ThemeController(bundled: bundledThemes);
wireThemePersistence(theme, settings);
final panels = PanelRegistry();
final arrangement = LayoutArrangement();
final commands = CommandRegistry();
final keybindings = KeybindingResolver();
final keymap = KeymapService(settings: settings, appDir: appDir);
await keymap.load();
final palette = PaletteController(commands);
final recentFiles = RecentFilesService();
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard();
final files = FileServices(events);
final notify = Notifications(messages: messages);
final dialog = DialogRouter();
final tray = TrayRegistry();
final secrets = SecretsVault();
final os = OsBridge(log: log, events: events);
final net = NetworkStatus();
final focus = FocusTracker();
final window = WindowControls();
final tc = toolchain ?? Toolchain();
final scheduler = SchedulerService(events);
scheduler.start();
final textZoom = TextZoom();
final toast = ToastService(messages: messages);
final project = ProjectManager(
log: log,
events: events,
settings: settings,
toolchain: tc,
onProjectOpen: onProjectOpen,
onValidateProject: onValidateProject,
);
final ipc =
isolateClient ??
(daemonClientFactory != null
? daemonClientFactory(log, events, arrangement, panels)
: DaemonClient.unixSocket(
// Legacy socket-client fallback — kept until T-127
// replaces it with the in-process socket loopback.
// Today nothing in production hits this branch
// (main.dart and the test harness pass an explicit
// daemonClientFactory). If a caller does land here
// without `autoStartDaemonClient: false`, the
// placeholder path makes the failure mode obvious.
socketPath: socketPath ?? '/dev/null/clide-legacy.sock',
log: log,
events: events,
));
final extensions = ExtensionManager(
log: log,
events: events,
messages: messages,
settings: settings,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
readerNav: readerNav,
keybindings: keybindings,
keymap: keymap,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
ipc: ipc,
);
if (autoStartDaemonClient) {
unawaited(ipc.start());
}
return KernelServices(
log: log,
logRing: logRing,
settings: settings,
events: events,
messages: messages,
filterStates: filterStates,
ipc: ipc,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
quickOpen: quickOpen,
recentFiles: recentFiles,
readerNav: readerNav,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
extensions: extensions,
window: window,
toolchain: tc,
scheduler: scheduler,
keymap: keymap,
textZoom: textZoom,
toast: toast,
);
}
Future<void> dispose() async {
await ipc.stop();
ipc.dispose();
settings.dispose();
theme.dispose();
panels.dispose();
arrangement.dispose();
commands.dispose();
palette.dispose();
quickOpen.dispose();
toast.dispose();
recentFiles.dispose();
readerNav.dispose();
i18n.dispose();
notify.dispose();
dialog.dispose();
tray.dispose();
net.dispose();
focus.dispose();
project.dispose();
extensions.dispose();
await scheduler.dispose();
keymap.dispose();
textZoom.dispose();
await log.dispose();
filterStates.dispose();
messages.dispose();
await events.dispose();
}
}
class ClideKernel extends InheritedWidget {
const ClideKernel({super.key, required this.services, required super.child});
final KernelServices services;
static KernelServices of(BuildContext context) {
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
if (w == null) {
throw FlutterError('ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
}
return w.services;
}
@override
bool updateShouldNotify(ClideKernel oldWidget) => services != oldWidget.services;
}