diff --git a/Logger.cs b/Logger.cs
index 0daeaae..c8773ee 100644
--- a/Logger.cs
+++ b/Logger.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
namespace BootstrapMate
@@ -15,6 +16,12 @@ public enum LogLevel
public static class Logger
{
private static string? LogFile;
+ ///
+ /// The run's session directory and its structured files. Null only when the
+ /// session directory could not be created, in which case the run falls back
+ /// to a flat per-run file at the logs root.
+ ///
+ private static SessionLog? _session;
private static bool _verboseConsole = false;
private static bool _silentMode = false;
private static DateTime _sessionStartTime;
@@ -26,7 +33,7 @@ public static class Logger
///
public static void SetPipeWriter(TextWriter? writer) => _pipeWriter = writer;
- public static void Initialize(string logDirectory, string version = "Unknown", bool verboseConsole = false, bool silentMode = false)
+ public static void Initialize(string logDirectory, string version = "Unknown", bool verboseConsole = false, bool silentMode = false, string runType = "provisioning")
{
try
{
@@ -40,10 +47,18 @@ public static void Initialize(string logDirectory, string version = "Unknown", b
Directory.CreateDirectory(logDirectory);
}
- LogFile = Path.Combine(logDirectory, $"{DateTime.Now:yyyy-MM-dd-HHmmss}.log");
-
+ // Retention runs before the run opens its own directory: day directories
+ // past the window, session directories past the cap, and the loose per-run
+ // files the flat layout left at the root.
+ SessionLog.Prune(logDirectory, BootstrapMate.Core.BootstrapMateConstants.LogRetentionDays, _sessionStartTime);
PruneExpiredLogs(logDirectory);
+ // This run's session directory: logs\YYYY-MM-DD\HHMMSS\, holding
+ // bootstrap.log beside events.jsonl and session.json.
+ _session = SessionLog.Create(logDirectory, version, runType, _sessionStartTime);
+ LogFile = _session?.LogFilePath
+ ?? Path.Combine(logDirectory, $"{_sessionStartTime:yyyy-MM-dd-HHmmss}.log");
+
// Write session header to log file
WriteToFile(LogLevel.Info, "=== BootstrapMate Session Started ===");
WriteToFile(LogLevel.Info, $"Version: {version}");
@@ -200,15 +215,24 @@ private static void WriteToFile(LogLevel level, string message)
{
var now = DateTime.Now;
var builder = new System.Text.StringBuilder();
+ var records = new List();
foreach (var line in StripDecoration(message).Split('\n'))
{
var text = line.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(text)) continue;
builder.Append(FormatLine(level, text, now)).Append(Environment.NewLine);
+ records.Add(text);
}
if (builder.Length > 0)
File.AppendAllText(LogFile, builder.ToString());
+
+ // The same records, structured, one JSON object per physical line.
+ if (_session is not null)
+ {
+ var label = FileLevel(level);
+ foreach (var text in records) _session.Append(label, text, now);
+ }
}
catch
{
@@ -408,6 +432,10 @@ public static void WriteSessionSummary()
WriteToFile(LogLevel.Info, $"=== BootstrapMate Session Ended === (Duration: {duration.TotalSeconds:F1}s)");
WriteToFile(LogLevel.Info, $"Session End Time: {timestamp}");
WriteToFile(LogLevel.Info, $"Total Session Duration: {duration.TotalMinutes:F2} minutes");
+ _session?.Finish();
}
+
+ /// The session id of the run in progress, when it has a session directory.
+ public static string? GetSessionId() => _session?.SessionId;
}
}
diff --git a/SessionLog.cs b/SessionLog.cs
new file mode 100644
index 0000000..59bf49d
--- /dev/null
+++ b/SessionLog.cs
@@ -0,0 +1,294 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace BootstrapMate
+{
+ ///
+ /// The structured half of a run's logs.
+ ///
+ ///
+ /// Every run owns a session directory under the tool's logs root,
+ /// C:\ProgramData\ManagedBootstrap\logs\YYYY-MM-DD\HHMMSS\, holding
+ /// bootstrap.log (the human log, written by ), events.jsonl
+ /// (one JSON record per line, appended as the run proceeds) and session.json
+ /// (the run as a whole, written when it starts and rewritten when it ends).
+ /// The layout and field names match Cimian's session logger and the macOS
+ /// BootstrapMate's, so the same readers work on every managed tool.
+ ///
+ public sealed class SessionLog
+ {
+ /// Session directories kept across all days, newest first.
+ public const int MaxSessions = 100;
+
+ private static readonly JsonSerializerOptions EventOptions = new()
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private static readonly JsonSerializerOptions SessionOptions = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public string SessionId { get; }
+ public string SessionDirectory { get; }
+ public string LogFilePath { get; }
+
+ private readonly DateTime _startTime;
+ private readonly string _runType;
+ private readonly string _version;
+ private readonly string _eventsPath;
+ private readonly object _writeLock = new();
+ private int _eventIndex;
+ private int _errors;
+ private int _warnings;
+ private int _events;
+
+ private SessionLog(string sessionDirectory, string sessionId, DateTime start, string runType, string version)
+ {
+ SessionDirectory = sessionDirectory;
+ SessionId = sessionId;
+ LogFilePath = Path.Combine(sessionDirectory, "bootstrap.log");
+ _startTime = start;
+ _runType = runType;
+ _version = version;
+ _eventsPath = Path.Combine(sessionDirectory, "events.jsonl");
+ WriteSessionFile("running");
+ }
+
+ ///
+ /// Creates logs\YYYY-MM-DD\HHMMSS\, appending _2 through _9
+ /// when a previous run started in the same second. Returns null when the directory
+ /// cannot be created, which leaves the caller to fall back to a flat file.
+ ///
+ public static SessionLog? Create(string logsDirectory, string version, string runType, DateTime start)
+ {
+ try
+ {
+ var day = start.ToString("yyyy-MM-dd");
+ var time = start.ToString("HHmmss");
+ var dayDirectory = Path.Combine(logsDirectory, day);
+ var directory = Path.Combine(dayDirectory, time);
+ var name = time;
+
+ if (Directory.Exists(directory))
+ {
+ var placed = false;
+ for (var suffix = 2; suffix <= 9; suffix++)
+ {
+ var candidate = Path.Combine(dayDirectory, $"{time}_{suffix}");
+ if (Directory.Exists(candidate)) continue;
+ directory = candidate;
+ name = $"{time}_{suffix}";
+ placed = true;
+ break;
+ }
+ if (!placed) return null;
+ }
+
+ Directory.CreateDirectory(directory);
+ return new SessionLog(directory, $"{day}-{name}", start, runType, version);
+ }
+ catch
+ {
+ // A session directory is a convenience; a run must never fail over one.
+ return null;
+ }
+ }
+
+ ///
+ /// Appends one record to events.jsonl and keeps the run's counts.
+ /// is the same text the human log carries, with any leading [TAG] lifted into the
+ /// event's type and status.
+ ///
+ public void Append(string level, string message, DateTime timestamp)
+ {
+ var (eventType, status, text) = Classify(level, message);
+ lock (_writeLock)
+ {
+ if (level == "ERROR") _errors++;
+ else if (level == "WARN") _warnings++;
+ _events++;
+ _eventIndex++;
+
+ var record = new SessionEvent
+ {
+ EventId = $"{SessionId}-{_eventIndex:D5}",
+ SessionId = SessionId,
+ Timestamp = timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"),
+ Level = level,
+ EventType = eventType,
+ Status = status,
+ Message = text,
+ Error = level == "ERROR" ? text : null
+ };
+
+ try
+ {
+ File.AppendAllText(_eventsPath, JsonSerializer.Serialize(record, EventOptions) + Environment.NewLine);
+ }
+ catch
+ {
+ // Structured logging is best-effort and must never stop a run.
+ }
+ }
+ }
+
+ /// Rewrites session.json with the run's outcome.
+ public void Finish(string? status = null, DateTime? end = null)
+ {
+ var finished = end ?? DateTime.Now;
+ var resolved = status ?? (_errors > 0 ? "partial_failure" : "completed");
+ WriteSessionFile(resolved, finished);
+ }
+
+ private void WriteSessionFile(string status, DateTime? end = null)
+ {
+ var record = new SessionRecord
+ {
+ SessionId = SessionId,
+ StartTime = _startTime.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"),
+ EndTime = end?.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"),
+ DurationSeconds = end.HasValue ? (int)Math.Round((end.Value - _startTime).TotalSeconds) : null,
+ RunType = _runType,
+ Status = status,
+ ToolVersion = _version,
+ Environment = new Dictionary
+ {
+ ["hostname"] = System.Environment.MachineName,
+ ["os_version"] = System.Environment.OSVersion.ToString(),
+ ["user"] = System.Environment.UserName,
+ ["pid"] = System.Environment.ProcessId.ToString(),
+ ["command_line"] = System.Environment.CommandLine
+ },
+ Summary = new SessionSummary { Events = _events, Errors = _errors, Warnings = _warnings }
+ };
+
+ try
+ {
+ File.WriteAllText(Path.Combine(SessionDirectory, "session.json"),
+ JsonSerializer.Serialize(record, SessionOptions));
+ }
+ catch
+ {
+ // Best-effort, as above.
+ }
+ }
+
+ ///
+ /// Lifts a leading [TAG] off a message into an event type and status, so the
+ /// structured stream carries what the human log carries in prose. An unrecognised
+ /// bracket is left in the message rather than invented into a type.
+ ///
+ internal static (string EventType, string? Status, string Message) Classify(string level, string message)
+ {
+ var fallbackType = level == "ERROR" ? "error" : "message";
+ var fallbackStatus = level == "ERROR" ? "FAILED" : null;
+ if (!message.StartsWith('[')) return (fallbackType, fallbackStatus, message);
+ var close = message.IndexOf(']');
+ if (close < 0) return (fallbackType, fallbackStatus, message);
+
+ var tag = message.Substring(1, close - 1).ToUpperInvariant();
+ var text = message[(close + 1)..].TrimStart(' ');
+ return tag switch
+ {
+ "SECTION" => ("section", null, text),
+ "PROGRESS" or "SUB-PROGRESS" => ("progress", "PROGRESS", text),
+ "SUCCESS" => ("item", "SUCCESS", text),
+ "SKIPPED" => ("item", "SKIPPED", text),
+ "COMPLETION" => ("session_end", "SUCCESS", text),
+ "OUTPUT" => ("output", null, text),
+ _ => (fallbackType, fallbackStatus, message)
+ };
+ }
+
+ ///
+ /// Removes day directories older than the retention window, then the oldest session
+ /// directories beyond the cap. Loose per-run files left at the logs root by the flat
+ /// layout this replaced are swept separately, by the same age rule, in
+ /// .
+ ///
+ internal static int Prune(string logsDirectory, int retentionDays, DateTime now)
+ {
+ var removed = 0;
+ try
+ {
+ if (!Directory.Exists(logsDirectory)) return 0;
+ var cutoff = now.AddDays(-retentionDays).Date;
+ var dayDirectories = Directory.GetDirectories(logsDirectory)
+ .Select(path => (Path: path, Name: Path.GetFileName(path)))
+ .Where(entry => DateTime.TryParseExact(entry.Name, "yyyy-MM-dd",
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None, out _))
+ .OrderByDescending(entry => entry.Name, StringComparer.Ordinal)
+ .ToList();
+
+ var surviving = new List<(string Path, string Name)>();
+ foreach (var entry in dayDirectories)
+ {
+ var day = DateTime.ParseExact(entry.Name, "yyyy-MM-dd",
+ System.Globalization.CultureInfo.InvariantCulture);
+ if (day < cutoff)
+ {
+ try { Directory.Delete(entry.Path, recursive: true); removed++; } catch { }
+ }
+ else
+ {
+ surviving.Add(entry);
+ }
+ }
+
+ var sessions = surviving
+ .SelectMany(entry => Directory.GetDirectories(entry.Path)
+ .OrderByDescending(Path.GetFileName, StringComparer.Ordinal))
+ .ToList();
+ foreach (var path in sessions.Skip(MaxSessions))
+ {
+ try { Directory.Delete(path, recursive: true); removed++; } catch { }
+ }
+ }
+ catch
+ {
+ // Retention is best-effort and must never stop a bootstrap run.
+ }
+ return removed;
+ }
+
+ private sealed class SessionEvent
+ {
+ [JsonPropertyName("event_id")] public string EventId { get; set; } = "";
+ [JsonPropertyName("session_id")] public string SessionId { get; set; } = "";
+ [JsonPropertyName("timestamp")] public string Timestamp { get; set; } = "";
+ [JsonPropertyName("level")] public string Level { get; set; } = "";
+ [JsonPropertyName("event_type")] public string EventType { get; set; } = "";
+ [JsonPropertyName("status")] public string? Status { get; set; }
+ [JsonPropertyName("message")] public string Message { get; set; } = "";
+ [JsonPropertyName("error")] public string? Error { get; set; }
+ }
+
+ private sealed class SessionSummary
+ {
+ [JsonPropertyName("events")] public int Events { get; set; }
+ [JsonPropertyName("errors")] public int Errors { get; set; }
+ [JsonPropertyName("warnings")] public int Warnings { get; set; }
+ }
+
+ private sealed class SessionRecord
+ {
+ [JsonPropertyName("session_id")] public string SessionId { get; set; } = "";
+ [JsonPropertyName("start_time")] public string StartTime { get; set; } = "";
+ [JsonPropertyName("end_time")] public string? EndTime { get; set; }
+ [JsonPropertyName("duration_seconds")] public int? DurationSeconds { get; set; }
+ [JsonPropertyName("run_type")] public string RunType { get; set; } = "";
+ [JsonPropertyName("status")] public string Status { get; set; } = "";
+ [JsonPropertyName("tool_version")] public string ToolVersion { get; set; } = "";
+ [JsonPropertyName("environment")] public Dictionary Environment { get; set; } = new();
+ [JsonPropertyName("summary")] public SessionSummary Summary { get; set; } = new();
+ }
+ }
+}
diff --git a/src/BootstrapMate.App/ViewModels/LogsViewModel.cs b/src/BootstrapMate.App/ViewModels/LogsViewModel.cs
index 1bd8065..1319028 100644
--- a/src/BootstrapMate.App/ViewModels/LogsViewModel.cs
+++ b/src/BootstrapMate.App/ViewModels/LogsViewModel.cs
@@ -70,17 +70,28 @@ public void Refresh()
if (!Directory.Exists(LogDirectory))
return;
- var files = Directory.GetFiles(LogDirectory, "*.log")
+ // A run is a session directory, logs\YYYY-MM-DD\HHMMSS\bootstrap.log. Flat
+ // per-run files at the root predate that layout and are still listed.
+ var sessions = Directory.GetDirectories(LogDirectory)
+ .SelectMany(day => Directory.GetDirectories(day)
+ .Select(session => (Day: System.IO.Path.GetFileName(day), Session: System.IO.Path.GetFileName(session), Path: session)))
+ .Select(entry =>
+ {
+ var log = Directory.GetFiles(entry.Path, "*.log")
+ .OrderBy(path => System.IO.Path.GetFileName(path) == "bootstrap.log" ? 0 : 1)
+ .FirstOrDefault();
+ return log is null ? null : new LogFile($"{entry.Day}-{entry.Session}", log, ParseStamp($"{entry.Day}-{entry.Session}"), FileSize(log));
+ })
+ .OfType();
+
+ var loose = Directory.GetFiles(LogDirectory, "*.log")
.Select(path =>
{
var name = System.IO.Path.GetFileName(path);
- var baseName = System.IO.Path.GetFileNameWithoutExtension(name);
- DateTime? date = DateTime.TryParseExact(baseName, "yyyy-MM-dd-HHmmss",
- CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : null;
- long size = 0;
- try { size = new FileInfo(path).Length; } catch { }
- return new LogFile(name, path, date, size);
- })
+ return new LogFile(name, path, ParseStamp(System.IO.Path.GetFileNameWithoutExtension(name)), FileSize(path));
+ });
+
+ var files = sessions.Concat(loose)
.OrderByDescending(f => f.Date ?? DateTime.MinValue)
.ToList();
@@ -92,6 +103,25 @@ public void Refresh()
SelectedLog = LogFiles[0];
}
+ ///
+ /// A session or flat-file stamp, at second or minute resolution. The minute form
+ /// predates seconds and still appears on a device that has not been rebuilt.
+ ///
+ private static DateTime? ParseStamp(string stamp)
+ {
+ foreach (var format in new[] { "yyyy-MM-dd-HHmmss", "yyyy-MM-dd-HHmm" })
+ {
+ if (DateTime.TryParseExact(stamp, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
+ return parsed;
+ }
+ return null;
+ }
+
+ private static long FileSize(string path)
+ {
+ try { return new FileInfo(path).Length; } catch { return 0; }
+ }
+
// ── Load Content ─────────────────────────────────────────────
partial void OnSelectedLogChanged(LogFile? value)