Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ private static void Append(
// (project_baddata_contentas_pattern.)
var log = node.ContentAs<ActivityLog>(workspace.Hub.JsonSerializerOptions, logger);
if (log is null) return node;
return node with { Content = log with { Messages = log.Messages.AddRange(messages) } };
return node with { Content = log.Append(messages) };
}).Subscribe(_ => { }, ex => logger?.LogWarning(ex, "Indexing activity log append failed for {Path}", activityPath));
}
}
Expand All @@ -241,13 +241,13 @@ private static IObservable<Unit> Finish(
// indexing-activity timeout). See project_baddata_contentas_pattern.
var log = node.ContentAs<ActivityLog>(workspace.Hub.JsonSerializerOptions, logger);
if (log is null) return node;
var messages = string.IsNullOrEmpty(finalMessage)
? log.Messages
: log.Messages.Add(new LogMessage(finalMessage,
var withFinal = string.IsNullOrEmpty(finalMessage)
? log
: log.Append(new LogMessage(finalMessage,
status == ActivityStatus.Failed ? LogLevel.Error : LogLevel.Information));
return node with
{
Content = log with { Messages = messages, Status = status, End = DateTime.UtcNow, RequestedStatus = null }
Content = withFinal with { Status = status, End = DateTime.UtcNow, RequestedStatus = null }
};
}).Select(_ => Unit.Default);
}
Expand Down
95 changes: 89 additions & 6 deletions src/MeshWeaver.Data.Contract/ActivityLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ public record ActivityLog(string Category)
public string Id { get; init; } = Guid.NewGuid().AsString();
/// <summary>The log messages accumulated by this activity, in order.</summary>
public ImmutableList<LogMessage> Messages { get; init; } = ImmutableList<LogMessage>.Empty;

/// <summary>
/// How many messages this activity has appended in total — <see cref="Messages"/> plus anything
/// no longer held there. Maintained incrementally by <see cref="Append(IReadOnlyList{LogMessage})"/>.
/// <para>🚨 Read it through <see cref="TotalMessageCount"/>, never directly: a log persisted before
/// this field existed carries 0 while <see cref="Messages"/> is non-empty.</para>
/// </summary>
public int MessageCount { get; init; }

/// <summary>
/// The highest <see cref="LogLevel"/> ever appended to this activity — an incremental roll-up so the
/// terminal status is a <b>counter</b>, not a scan of the message list.
/// <para>🚨 <see cref="LogLevel.None"/> is never rolled up (it is numerically the largest level but
/// means "no logging", not "worse than Critical"). The default is <see cref="LogLevel.Trace"/>, which
/// is the identity element for the max — so a log persisted before this field existed rolls up to
/// exactly what a scan of its <see cref="Messages"/> yields.</para>
/// </summary>
public LogLevel MaxSeverity { get; init; } = LogLevel.Trace;

/// <summary>The current status of the activity.</summary>
public ActivityStatus Status { get; init; }

Expand Down Expand Up @@ -70,16 +89,52 @@ public record ActivityLog(string Category)
/// </summary>
public string? PrimaryNodePath => HubPath;

/// <summary>
/// How many messages this activity has recorded, counting any no longer held in
/// <see cref="Messages"/>. Back-compatible with logs persisted before <see cref="MessageCount"/>
/// existed (those carry 0, so the in-list count wins).
/// </summary>
public int TotalMessageCount => Math.Max(MessageCount, Messages.Count);

/// <summary>
/// The ONE way to add messages to an activity log: appends them to <see cref="Messages"/> and rolls
/// <see cref="MessageCount"/> and <see cref="MaxSeverity"/> forward in the same step.
/// <para>🚨 Never write <c>Messages = Messages.Add(…)</c> directly — the roll-ups are what make the
/// terminal status independent of how many messages the list still holds.</para>
/// </summary>
/// <param name="messages">The messages to append, in order. An empty list is a no-op.</param>
/// <returns>The activity log with the messages appended and the roll-ups advanced.</returns>
public ActivityLog Append(IReadOnlyList<LogMessage> messages)
{
if (messages.Count == 0) return this;
var severity = MaxSeverity;
foreach (var m in messages)
if (m.LogLevel > severity && m.LogLevel != LogLevel.None)
severity = m.LogLevel;
return this with
{
Messages = Messages.AddRange(messages),
MessageCount = TotalMessageCount + messages.Count,
MaxSeverity = severity,
};
}
Comment on lines +107 to +120

/// <summary>
/// Convenience overload of <see cref="Append(IReadOnlyList{LogMessage})"/> for a single message.
/// </summary>
/// <param name="message">The message to append.</param>
/// <returns>The activity log with the message appended and the roll-ups advanced.</returns>
public ActivityLog Append(LogMessage message) => Append([message]);

/// <summary>
/// Returns a copy of this activity marked <see cref="ActivityStatus.Failed"/> with the
/// given error appended as an error message and <see cref="End"/> set to now.
/// </summary>
/// <param name="error">The error message to record.</param>
/// <returns>The failed activity log.</returns>
public ActivityLog Fail(string error) =>
this with
Append(new LogMessage(error, LogLevel.Error)) with
{
Messages = Messages.Add(new LogMessage(error, LogLevel.Error)),
Status = ActivityStatus.Failed,
End = DateTime.UtcNow,
};
Expand All @@ -101,18 +156,41 @@ this with
Version = version
};

/// <summary>
/// The status implied by what has been logged: the worse of the sub-activities' statuses and the
/// severity roll-up.
/// <para>🚨 Derived from <see cref="MaxSeverity"/> — a counter — <b>not</b> from a scan of
/// <see cref="Messages"/>, so it stays correct (and O(1)) when the list is a bounded window rather
/// than the whole transcript. The list is still folded in so a log written before
/// <see cref="MaxSeverity"/> existed, or one mutated outside <see cref="Append(IReadOnlyList{LogMessage})"/>,
/// yields exactly the pre-roll-up answer.</para>
/// </summary>
private ActivityStatus GetFinalStatus()
{
var subActivityStatus = SubActivities
.Select(s => s.Status)
.DefaultIfEmpty(ActivityStatus.Succeeded)
.Max();

var maxLevel = Messages.Select(m => m.LogLevel).DefaultIfEmpty(LogLevel.Information).Max();
// The roll-up joins the fold as one more element. Its default (Trace) and the old
// empty-list default (Information) both land in the `_ => Succeeded` arm below, so seeding
// with the roll-up instead of Information cannot change the answer for a log that never
// recorded a Warning or worse.
// 🚨 LogLevel.None is excluded from BOTH sides of the fold. It is numerically above Critical
// but means "no logging", so a single None entry used to become the list's Max and drop the
// whole activity into the `_ => Succeeded` arm — swallowing a real Error logged beside it.
// Beyond being wrong, agreement here is what makes the message window safe: the status must
// come out the same whether a message is still in Messages or has been flushed into
// MaxSeverity, and only one of those two paths can see a None entry.
var maxLevel = Messages.Select(m => m.LogLevel)
.Append(MaxSeverity)
.Where(l => l != LogLevel.None)
.DefaultIfEmpty(LogLevel.Information)
.Max();
var mapToStatus = maxLevel switch
{
LogLevel.Critical or LogLevel.Error => ActivityStatus.Failed,
LogLevel.Warning => ActivityStatus.Warning,
LogLevel.Warning => ActivityStatus.Warning,
_ => ActivityStatus.Succeeded
};

Expand All @@ -122,10 +200,15 @@ private ActivityStatus GetFinalStatus()
}

/// <summary>
/// Returns true if this activity's own messages contain at least one error.
/// Returns true if this activity has recorded at least one error (or critical) message.
/// Answered from the <see cref="MaxSeverity"/> roll-up so it does not depend on how much of the
/// transcript <see cref="Messages"/> still holds; the list is folded in for logs written before
/// the roll-up existed.
/// </summary>
/// <returns>True if an error message is present; otherwise false.</returns>
public bool HasErrors() => Messages.Any(m => m.LogLevel == LogLevel.Error);
public bool HasErrors() =>
MaxSeverity is LogLevel.Error or LogLevel.Critical
|| Messages.Any(m => m.LogLevel == LogLevel.Error);

/// <summary>
/// This log plus, recursively, every <see cref="SubActivities"/> entry — flattened so a
Expand Down
4 changes: 2 additions & 2 deletions src/MeshWeaver.Data/Activity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public void LogInformation(string message, IReadOnlyCollection<KeyValuePair<stri
/// <param name="logLevel">Severity of the message.</param>
/// <param name="scopes">Optional structured scopes attached to the message.</param>
public void LogMessage(string message, LogLevel logLevel, IReadOnlyCollection<KeyValuePair<string, object>>? scopes = null)
=> MutateLog(log => log with { Messages = log.Messages.Add(new LogMessage(message, logLevel) { Scopes = scopes }) });
=> MutateLog(log => log.Append(new LogMessage(message, logLevel) { Scopes = scopes }));

/// <summary>
/// Reactive snapshot of the current <see cref="ActivityLog"/>, read on the activity hub's
Expand Down Expand Up @@ -126,7 +126,7 @@ public void RecordAffectedPaths(IEnumerable<string> paths)
/// <param name="exception">Optional exception associated with the entry.</param>
/// <param name="formatter">Function that renders <paramref name="state"/> and <paramref name="exception"/> to text.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> MutateLog(log => log with { Messages = log.Messages.Add(new LogMessage(formatter.Invoke(state, exception), logLevel)) });
=> MutateLog(log => log.Append(new LogMessage(formatter.Invoke(state, exception), logLevel)));

/// <summary>Returns whether the given log level is enabled on the underlying logger.</summary>
/// <param name="logLevel">Level to test.</param>
Expand Down
2 changes: 1 addition & 1 deletion src/MeshWeaver.Data/WorkspaceOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ private static IObservable<ImmutableList<LogMessage>> UpdateStream(

/// <summary>Finishes a data-update log: status is rolled up from the message levels.</summary>
private static ActivityLog Finish(IMessageHub hub, ImmutableList<LogMessage> messages) =>
new ActivityLog(ActivityCategory.DataUpdate) { Messages = messages }
new ActivityLog(ActivityCategory.DataUpdate).Append(messages)
.Finish((int)hub.Version, null);

// Maps an instance to its routing tuple. An EntityDeltaUpdate (a minimal-bytes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
Name: Activity status no longer misses errors
Category: Fix
Description: An activity's final status is now rolled up from counters as it runs, so an error can no longer be swallowed by a silenced log entry.
Icon: CheckmarkCircle
Order: -20260813
---

# What's New — 13 August 2026

## Activity status no longer misses errors

An activity's final status — Succeeded, Warning, Failed — was worked out at the end by scanning its whole message list for the worst severity. That let a single entry logged at the "None" level, which means *no logging* rather than *worse than critical*, come out as the highest severity in the list and drop the entire activity into Succeeded, hiding real errors logged beside it.

Severity is now rolled up as each message is recorded, so the outcome is decided by a counter rather than by a scan, and a silenced entry can no longer outrank a genuine error. "Has errors" now also reports critical-level messages, matching the error list that has always included them.
10 changes: 5 additions & 5 deletions src/MeshWeaver.GitSync/ActivityRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ private static void Append(
// content can arrive as a degraded JsonElement — `is ActivityLog` would silently
// no-op and the progress line would never land (same read as the cancel watch).
if (node.ContentAs<ActivityLog>(workspace.Hub.JsonSerializerOptions, logger) is not { } log) return node;
return node with { Content = log with { Messages = log.Messages.Add(new LogMessage(message, level)) } };
return node with { Content = log.Append(new LogMessage(message, level)) };
}).Subscribe(_ => { }, ex => logger?.LogWarning(ex, "Activity log append failed for {Path}", activityPath));
}
}
Expand All @@ -277,9 +277,9 @@ private static IObservable<Unit> Finish(
// content can arrive as a degraded JsonElement — `is ActivityLog` would silently
// no-op and the activity would hang Running forever (same read as the cancel watch).
if (node.ContentAs<ActivityLog>(workspace.Hub.JsonSerializerOptions, logger) is not { } log) return node;
var messages = string.IsNullOrEmpty(finalMessage)
? log.Messages
: log.Messages.Add(new LogMessage(finalMessage,
var withFinal = string.IsNullOrEmpty(finalMessage)
? log
: log.Append(new LogMessage(finalMessage,
status == ActivityStatus.Failed ? LogLevel.Error : LogLevel.Information));
// Honour what the command reported via ctx.Log: ActivityLog.Finish computes
// MAX(status, roll-up from Messages) — an Error line a command appended flips
Expand All @@ -289,7 +289,7 @@ private static IObservable<Unit> Finish(
// throwing would end "Succeeded" with errors in its own log.
// The hub's current version stamps the finished log — the same
// `(int)hub.Version` every other Finish caller records.
var finished = (log with { Messages = messages }).Finish((int)workspace.Hub.Version, status);
var finished = withFinal.Finish((int)workspace.Hub.Version, status);
return node with
{
Content = finished with { RequestedStatus = null }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,13 +574,13 @@ private IObservable<CompileAttempt> GetAssemblyLocationWithLog(
}

private static ActivityLog AppendInfo(ActivityLog log, string message)
=> log with { Messages = log.Messages.Add(new LogMessage(message, LogLevel.Information)) };
=> log.Append(new LogMessage(message, LogLevel.Information));

private static ActivityLog AppendWarning(ActivityLog log, string message)
=> log with { Messages = log.Messages.Add(new LogMessage(message, LogLevel.Warning)) };
=> log.Append(new LogMessage(message, LogLevel.Warning));

private static ActivityLog AppendError(ActivityLog log, string message)
=> log with { Messages = log.Messages.Add(new LogMessage(message, LogLevel.Error)) };
=> log.Append(new LogMessage(message, LogLevel.Error));

/// <summary>
/// Source-set discovery via the workspace SyncedQuery registry — one
Expand Down
20 changes: 6 additions & 14 deletions src/MeshWeaver.Graph/Configuration/NodeTypeCompilationActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,7 @@ public static void AppendLogs(
hub.GetWorkspace().GetMeshNodeStream(activityPath!)
.Update(current =>
current?.Content is ActivityLog log
? current with
{
Content = log with
{
Messages = log.Messages.AddRange(messages)
}
}
? current with { Content = log.Append(messages) }
: current!)
.Subscribe(
_ => { },
Expand Down Expand Up @@ -188,11 +182,10 @@ public static void Complete(
current?.Content is ActivityLog log
? current with
{
Content = log with
Content = log.Append(messages) with
{
Status = status,
End = DateTime.UtcNow,
Messages = log.Messages.AddRange(messages)
}
}
: current!)
Expand Down Expand Up @@ -250,14 +243,13 @@ private static void Update(
current?.Content is ActivityLog log
? current with
{
Content = log with
Content = (error is { Length: > 0 }
? log.Append(new LogMessage(error,
Microsoft.Extensions.Logging.LogLevel.Error))
: log) with
{
Status = status,
End = DateTime.UtcNow,
Messages = error is { Length: > 0 }
? log.Messages.Add(new LogMessage(error,
Microsoft.Extensions.Logging.LogLevel.Error))
: log.Messages
}
}
: current!)
Expand Down
12 changes: 9 additions & 3 deletions src/MeshWeaver.Graph/RunningActivitiesStripe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ public static MessageHubConfiguration AddRunningActivitiesStripe(this MessageHub

private static UiControl BuildRow(MeshNode activity, ActivityLog log, string? locale = null)
{
var label = $"{System.Net.WebUtility.HtmlEncode(log.Category)} · {log.Messages.Count} msg";
// TotalMessageCount, not Messages.Count: Messages is a bounded WINDOW — the caption must
// report how many lines the activity has produced, not how many it still holds.
var label = $"{System.Net.WebUtility.HtmlEncode(log.Category)} · {log.TotalMessageCount} msg";
var elapsed = log.End is null
? FormatElapsed(DateTime.UtcNow - log.Start)
: FormatElapsed(log.End.Value - log.Start);
Expand Down Expand Up @@ -162,8 +164,12 @@ public bool Equals(IReadOnlyList<MeshNode>? x, IReadOnlyList<MeshNode>? y)
// ContentAs (deserialize), not `as ActivityLog`: on JsonElement frames `as` → null →
// both counts collapse to 0 → the comparer reports "equal" while message counts actually
// diverge (or vice-versa), so the stripe both misses real updates AND churns spuriously.
var lx = x[i].ContentAs<ActivityLog>(options)?.Messages.Count ?? 0;
var ly = y[i].ContentAs<ActivityLog>(options)?.Messages.Count ?? 0;
// 🚨 TotalMessageCount, NOT Messages.Count. Messages is a bounded window that stops
// growing once an activity passes the window size — keying the DistinctUntilChanged on
// it would silently FREEZE the stripe for exactly the long-running activities it exists
// to show. The monotonic total is the progress signal.
var lx = x[i].ContentAs<ActivityLog>(options)?.TotalMessageCount ?? 0;
var ly = y[i].ContentAs<ActivityLog>(options)?.TotalMessageCount ?? 0;
if (lx != ly) return false;
}
return true;
Expand Down
Loading
Loading