diff --git a/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs b/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs
new file mode 100644
index 00000000..4fd3425b
--- /dev/null
+++ b/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs
@@ -0,0 +1,201 @@
+using Netclaw.Actors.Channels;
+using Netclaw.Actors.Protocol;
+using Netclaw.Configuration;
+using Xunit;
+
+namespace Netclaw.Actors.Tests.Channels;
+
+public sealed class ExecutionOutputAccumulatorTests
+{
+ private static readonly SessionId TestSessionId = new("test/session");
+
+ [Fact]
+ public void TextDeltaOutput_accumulates_text()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ var action1 = acc.ProcessOutput(new TextDeltaOutput { SessionId = TestSessionId, Delta = "Hello " });
+ var action2 = acc.ProcessOutput(new TextDeltaOutput { SessionId = TestSessionId, Delta = "world" });
+
+ Assert.Equal(OutputAction.Continue, action1);
+ Assert.Equal(OutputAction.Continue, action2);
+ Assert.Equal("Hello world", acc.GetAccumulatedText());
+ }
+
+ [Fact]
+ public void TextOutput_accumulates_when_no_prior_delta()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ acc.ProcessOutput(new TextOutput { SessionId = TestSessionId, Text = "Full text" });
+
+ Assert.Equal("Full text", acc.GetAccumulatedText());
+ }
+
+ [Fact]
+ public void TextOutput_ignored_after_TextDeltaOutput()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ acc.ProcessOutput(new TextDeltaOutput { SessionId = TestSessionId, Delta = "streamed" });
+ acc.ProcessOutput(new TextOutput { SessionId = TestSessionId, Text = "assembled" });
+
+ Assert.Equal("streamed", acc.GetAccumulatedText());
+ }
+
+ [Fact]
+ public void TurnCompleted_returns_TurnCompleted_action()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ var action = acc.ProcessOutput(new TurnCompleted { SessionId = TestSessionId, TurnNumber = 1 });
+
+ Assert.Equal(OutputAction.TurnCompleted, action);
+ }
+
+ [Fact]
+ public void ErrorOutput_returns_Error_action_and_stores_details()
+ {
+ var acc = new ExecutionOutputAccumulator();
+ var cause = new InvalidOperationException("inner");
+
+ var action = acc.ProcessOutput(new ErrorOutput
+ {
+ SessionId = TestSessionId,
+ Message = "Something failed",
+ Category = ErrorCategory.ProviderFailure,
+ Cause = cause
+ });
+
+ Assert.Equal(OutputAction.Error, action);
+ Assert.Equal("Something failed", acc.LastErrorMessage);
+ Assert.Equal(ErrorCategory.ProviderFailure, acc.LastErrorCategory);
+ Assert.Same(cause, acc.LastErrorCause);
+ }
+
+ [Fact]
+ public void BufferFlush_returns_Continue()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ var action = acc.ProcessOutput(new BufferFlush { SessionId = TestSessionId });
+
+ Assert.Equal(OutputAction.Continue, action);
+ }
+
+ [Fact]
+ public void Tracks_successful_notification_tool_result()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ acc.ProcessOutput(new ToolResultOutput
+ {
+ SessionId = TestSessionId,
+ CallId = "call-1",
+ ToolName = "send_slack_message",
+ Result = "Message sent to channel C1."
+ });
+
+ Assert.True(acc.NotifyAttempted);
+ Assert.False(acc.NotifyFailed);
+ }
+
+ [Fact]
+ public void Tracks_failed_notification_tool_result()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ acc.ProcessOutput(new ToolResultOutput
+ {
+ SessionId = TestSessionId,
+ CallId = "call-2",
+ ToolName = "send_slack_message",
+ Result = "Error: channel not found"
+ });
+
+ Assert.True(acc.NotifyAttempted);
+ Assert.True(acc.NotifyFailed);
+ }
+
+ [Fact]
+ public void Ignores_non_notification_tool_results()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ acc.ProcessOutput(new ToolResultOutput
+ {
+ SessionId = TestSessionId,
+ CallId = "call-3",
+ ToolName = "web_search",
+ Result = "Found 5 results"
+ });
+
+ Assert.False(acc.NotifyAttempted);
+ }
+
+ // ── BuildNotifyFailureMessage tests ──────────────────────────────────────
+
+ [Fact]
+ public void No_failure_when_no_notify_instructions()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ var result = acc.BuildNotifyFailureMessage(hasNotifyInstructions: false, NotificationPolicy.Required);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Required_policy_fails_when_no_notification_sent()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ var result = acc.BuildNotifyFailureMessage(hasNotifyInstructions: true, NotificationPolicy.Required);
+
+ Assert.Contains("no notification tool was invoked", result);
+ }
+
+ [Fact]
+ public void Conditional_policy_succeeds_when_no_notification_sent()
+ {
+ var acc = new ExecutionOutputAccumulator();
+
+ var result = acc.BuildNotifyFailureMessage(hasNotifyInstructions: true, NotificationPolicy.Conditional);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Succeeds_when_notification_attempted_and_succeeded()
+ {
+ var acc = new ExecutionOutputAccumulator();
+ acc.ProcessOutput(new ToolResultOutput
+ {
+ SessionId = TestSessionId,
+ CallId = "call-ok",
+ ToolName = "send_slack_message",
+ Result = "Message sent."
+ });
+
+ var result = acc.BuildNotifyFailureMessage(hasNotifyInstructions: true, NotificationPolicy.Required);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Fails_when_notification_attempted_and_errored()
+ {
+ var acc = new ExecutionOutputAccumulator();
+ acc.ProcessOutput(new ToolResultOutput
+ {
+ SessionId = TestSessionId,
+ CallId = "call-err",
+ ToolName = "send_slack_message",
+ Result = "Error: channel not found"
+ });
+
+ var result = acc.BuildNotifyFailureMessage(hasNotifyInstructions: true, NotificationPolicy.Conditional);
+
+ Assert.Contains("channel not found", result);
+ }
+}
diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/FailingSessionPipeline.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/FailingSessionPipeline.cs
new file mode 100644
index 00000000..dfac604c
--- /dev/null
+++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/FailingSessionPipeline.cs
@@ -0,0 +1,23 @@
+using Akka.Streams;
+using Netclaw.Actors.Channels;
+using Netclaw.Actors.Protocol;
+using Netclaw.Configuration;
+
+namespace Netclaw.Actors.Tests.Channels.TestHelpers;
+
+///
+/// Fake that throws a pre-configured exception
+/// on . Used to test initialization failure paths.
+///
+internal sealed class FailingSessionPipeline(Exception exception) : ISessionPipeline
+{
+ public Task CreateAsync(
+ SessionId sessionId,
+ SessionPipelineOptions options,
+ IMaterializer? materializer = null,
+ CancellationToken cancellationToken = default) =>
+ throw exception;
+
+ public Task SendFeedbackAsync(IWithSessionId feedback, CancellationToken ct = default) =>
+ Task.CompletedTask;
+}
diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/ScriptedSessionPipeline.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/ScriptedSessionPipeline.cs
new file mode 100644
index 00000000..61efad3a
--- /dev/null
+++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/ScriptedSessionPipeline.cs
@@ -0,0 +1,41 @@
+using Akka;
+using Akka.Streams;
+using Akka.Streams.Dsl;
+using Netclaw.Actors.Channels;
+using Netclaw.Actors.Protocol;
+using Netclaw.Configuration;
+
+namespace Netclaw.Actors.Tests.Channels.TestHelpers;
+
+///
+/// Fake that replays a scripted sequence of
+/// events. Input is discarded. Used by
+/// execution actor tests (reminders, webhooks) and pipeline handle tests.
+///
+internal sealed class ScriptedSessionPipeline(
+ Func> outputFactory) : ISessionPipeline
+{
+ public SessionPipelineOptions? CapturedOptions { get; private set; }
+
+ public Task CreateAsync(
+ SessionId sessionId,
+ SessionPipelineOptions options,
+ IMaterializer? materializer = null,
+ CancellationToken cancellationToken = default)
+ {
+ CapturedOptions = options;
+
+ var killSwitch = KillSwitches.Shared($"scripted-{sessionId.Value}");
+
+ var input = Sink.Ignore()
+ .MapMaterializedValue(_ => NotUsed.Instance);
+
+ var output = Source.From(outputFactory(sessionId).ToList())
+ .Via(killSwitch.Flow());
+
+ return Task.FromResult(new MaterializedSession(input, output, killSwitch));
+ }
+
+ public Task SendFeedbackAsync(IWithSessionId feedback, CancellationToken ct = default) =>
+ Task.CompletedTask;
+}
diff --git a/src/Netclaw.Actors/Channels/ChannelPipeline.cs b/src/Netclaw.Actors/Channels/ChannelPipeline.cs
index b4320e8d..ab7f38cc 100644
--- a/src/Netclaw.Actors/Channels/ChannelPipeline.cs
+++ b/src/Netclaw.Actors/Channels/ChannelPipeline.cs
@@ -302,7 +302,7 @@ private static SendUserMessage MapToCommand(
NetclawPaths paths)
{
var turnId = string.IsNullOrWhiteSpace(input.MessageId)
- ? Guid.NewGuid().ToString("N")[..8]
+ ? IdGen.ShortId()
: input.MessageId!;
var textParts = input.Contents.OfType().Select(t => t.Text);
diff --git a/src/Netclaw.Actors/Channels/ExecutionOutputAccumulator.cs b/src/Netclaw.Actors/Channels/ExecutionOutputAccumulator.cs
new file mode 100644
index 00000000..2d231205
--- /dev/null
+++ b/src/Netclaw.Actors/Channels/ExecutionOutputAccumulator.cs
@@ -0,0 +1,151 @@
+using System.Text;
+using Netclaw.Actors.Protocol;
+using Netclaw.Configuration;
+
+namespace Netclaw.Actors.Channels;
+
+///
+/// What the caller should do after processing an output via
+/// .
+///
+public enum OutputAction
+{
+ /// Output was accumulated; no caller action needed.
+ Continue,
+
+ /// Turn completed; caller should finalize and stop.
+ TurnCompleted,
+
+ /// Error received; caller should report failure and stop.
+ Error
+}
+
+///
+/// Tracks output accumulation and notification result state for fire-and-forget
+/// execution actors (reminders, webhooks). Pure C# — no Akka dependency.
+///
+public sealed class ExecutionOutputAccumulator
+{
+ private const string NotificationToolName = "send_slack_message";
+
+ private readonly StringBuilder _buffer = new();
+ private bool _sawTextDelta;
+ private bool _notifyAttempted;
+ private bool _notifyFailed;
+ private string? _notifyFailureDetail;
+
+ ///
+ /// The error message from the most recent ,
+ /// or null if no error has been received.
+ ///
+ public string? LastErrorMessage { get; private set; }
+
+ ///
+ /// The underlying exception from the most recent ,
+ /// or null if no error has been received or the error had no cause.
+ ///
+ public Exception? LastErrorCause { get; private set; }
+
+ ///
+ /// The of the most recent error, if any.
+ ///
+ public ErrorCategory? LastErrorCategory { get; private set; }
+
+ ///
+ /// Returns the accumulated text output, trimmed.
+ ///
+ public string GetAccumulatedText() => _buffer.ToString().Trim();
+
+ ///
+ /// Whether a notification tool was invoked during this execution.
+ ///
+ public bool NotifyAttempted => _notifyAttempted;
+
+ ///
+ /// Whether the most recent notification attempt failed.
+ ///
+ public bool NotifyFailed => _notifyFailed;
+
+ ///
+ /// Processes a and returns the action the caller should take.
+ ///
+ public OutputAction ProcessOutput(SessionOutput output)
+ {
+ switch (output)
+ {
+ case TextDeltaOutput delta:
+ _buffer.Append(delta.Delta);
+ _sawTextDelta = true;
+ return OutputAction.Continue;
+
+ case TextOutput text:
+ if (!_sawTextDelta)
+ _buffer.Append(text.Text);
+ return OutputAction.Continue;
+
+ case ToolResultOutput toolResult:
+ TrackNotificationResult(toolResult);
+ return OutputAction.Continue;
+
+ case BufferFlush:
+ return OutputAction.Continue;
+
+ case TurnCompleted:
+ return OutputAction.TurnCompleted;
+
+ case ErrorOutput err:
+ LastErrorMessage = err.Message;
+ LastErrorCause = err.Cause;
+ LastErrorCategory = err.Category;
+ return OutputAction.Error;
+
+ default:
+ return OutputAction.Continue;
+ }
+ }
+
+ ///
+ /// Evaluates notification policy to determine if the execution should be
+ /// considered a failure due to notification issues. Returns null on
+ /// success, or an error message string describing the notification failure.
+ ///
+ /// Whether notification instructions were configured.
+ /// The notification policy for this execution.
+ public string? BuildNotifyFailureMessage(bool hasNotifyInstructions, NotificationPolicy notifyPolicy)
+ {
+ if (!hasNotifyInstructions)
+ return null;
+
+ if (!_notifyAttempted)
+ {
+ if (notifyPolicy == NotificationPolicy.Conditional)
+ return null;
+
+ return "Notification instructions were provided but no notification tool was invoked.";
+ }
+
+ if (_notifyFailed)
+ return _notifyFailureDetail ?? "Notification tool returned an unspecified error.";
+
+ return null;
+ }
+
+ private void TrackNotificationResult(ToolResultOutput toolResult)
+ {
+ if (!string.Equals(toolResult.ToolName, NotificationToolName, StringComparison.Ordinal))
+ return;
+
+ _notifyAttempted = true;
+
+ var result = toolResult.Result?.Trim() ?? string.Empty;
+ if (result.StartsWith("Error", StringComparison.OrdinalIgnoreCase))
+ {
+ _notifyFailed = true;
+ _notifyFailureDetail = result;
+ return;
+ }
+
+ _notifyFailed = false;
+ _notifyFailureDetail = null;
+ }
+}
diff --git a/src/Netclaw.Actors/Channels/SessionPipelineHandle.cs b/src/Netclaw.Actors/Channels/SessionPipelineHandle.cs
new file mode 100644
index 00000000..266677c6
--- /dev/null
+++ b/src/Netclaw.Actors/Channels/SessionPipelineHandle.cs
@@ -0,0 +1,220 @@
+using System.Threading.Channels;
+using Akka.Actor;
+using Akka.Event;
+using Akka.Streams;
+using Akka.Streams.Dsl;
+using Netclaw.Actors.Protocol;
+using Netclaw.Configuration;
+
+namespace Netclaw.Actors.Channels;
+
+///
+/// Manages the lifecycle of a materialized session pipeline on behalf of an
+/// owning actor. Not thread-safe — designed for use within a single actor context
+/// (no concurrent access). Supports two modes:
+///
+/// - Long-lived (with reinitialization) for binding actors (Slack, SignalR)
+/// - Short-lived (fire-and-forget) for execution actors (Reminders, Webhooks)
+///
+///
+public sealed class SessionPipelineHandle : IAsyncDisposable
+{
+ private readonly ISessionPipeline _pipeline;
+ private readonly ILoggingAdapter _log;
+ private readonly string _materializerNamePrefix;
+
+ private ActorMaterializer? _materializer;
+ private MaterializedSession? _session;
+ private ChannelWriter? _inputQueue;
+ private int _pipelineGeneration;
+ private bool _isReinitializing;
+
+ public SessionPipelineHandle(
+ ISessionPipeline pipeline,
+ ILoggingAdapter log,
+ string materializerNamePrefix)
+ {
+ _pipeline = pipeline;
+ _log = log;
+ _materializerNamePrefix = materializerNamePrefix;
+ }
+
+ /// The current pipeline generation, for OutputStreamTerminated filtering.
+ public int Generation => _pipelineGeneration;
+
+ /// The for long-lived actors to write input.
+ /// Null if not initialized or if initialized via queue mode.
+ public ChannelWriter? InputQueue => _inputQueue;
+
+ /// Whether the handle has been initialized (session is not null).
+ public bool IsInitialized => _session is not null;
+
+ ///
+ /// Idempotent pipeline creation for long-lived actors that use
+ /// for ongoing input. Returns the for the caller to write input.
+ /// Wires output stream termination detection via the callback.
+ ///
+ /// The owning actor's context (used to scope the materializer).
+ /// Session identity.
+ /// Channel-specific pipeline options.
+ /// Callback invoked for each (typically output => self.Tell(new MyWrapper(output))).
+ /// Callback with (generation, cause) when the output stream terminates.
+ /// Cancellation token for pipeline creation.
+ /// The for writing input.
+ public async Task> InitializeWithChannelAsync(
+ IActorContext context,
+ SessionId sessionId,
+ SessionPipelineOptions options,
+ Action onOutput,
+ Action onStreamTerminated,
+ CancellationToken cancellationToken = default)
+ {
+ if (_session is not null)
+ return _inputQueue!;
+
+ _log.Info("Initializing {0} session pipeline", _materializerNamePrefix);
+
+ _materializer = context.Materializer(namePrefix: _materializerNamePrefix);
+
+ var materialized = await _pipeline.CreateAsync(
+ sessionId, options,
+ materializer: _materializer,
+ cancellationToken: cancellationToken);
+
+ var inputQueue = Source.Channel(512, true)
+ .ToMaterialized(materialized.Input, Keep.Left)
+ .Run(_materializer);
+
+ var generation = ++_pipelineGeneration;
+ var outputCompletion = materialized.Output
+ .ToMaterialized(
+ Sink.ForEach(onOutput),
+ Keep.Right)
+ .Run(_materializer);
+
+ _ = outputCompletion.ContinueWith(t =>
+ {
+ var cause = t.IsFaulted ? t.Exception?.GetBaseException() : null;
+ onStreamTerminated(generation, cause);
+ },
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+
+ _session = materialized;
+ _inputQueue = inputQueue;
+
+ _log.Info("{0} session pipeline initialized", _materializerNamePrefix);
+ return inputQueue;
+ }
+
+ ///
+ /// Pipeline creation for fire-and-forget execution actors that use
+ /// , offer input once, and complete.
+ /// Does not wire stream-terminated detection (the actor stops on TurnCompleted/ErrorOutput).
+ ///
+ /// The owning actor's context (used to scope the materializer).
+ /// Session identity.
+ /// Channel-specific pipeline options.
+ /// Callback invoked for each .
+ /// Cancellation token for pipeline creation.
+ /// The for offering input and completing.
+ public async Task> InitializeWithQueueAsync(
+ IActorContext context,
+ SessionId sessionId,
+ SessionPipelineOptions options,
+ Action onOutput,
+ CancellationToken cancellationToken = default)
+ {
+ _log.Info("Initializing {0} execution pipeline", _materializerNamePrefix);
+
+ _materializer = context.Materializer(namePrefix: _materializerNamePrefix);
+
+ var materialized = await _pipeline.CreateAsync(
+ sessionId, options,
+ materializer: _materializer,
+ cancellationToken: cancellationToken);
+
+ var inputQueue = Source.Queue(8, OverflowStrategy.Backpressure)
+ .ToMaterialized(materialized.Input, Keep.Left)
+ .Run(_materializer);
+
+ materialized.Output
+ .To(Sink.ForEach(onOutput))
+ .Run(_materializer);
+
+ _session = materialized;
+
+ _log.Info("{0} execution pipeline initialized", _materializerNamePrefix);
+ return inputQueue;
+ }
+
+ ///
+ /// Tears down the current pipeline and re-initializes. Guards against concurrent
+ /// reinitialization. On failure, invokes .
+ /// Only used by long-lived binding actors.
+ ///
+ public async Task ReinitializeAsync(
+ string reason,
+ IActorContext context,
+ SessionId sessionId,
+ SessionPipelineOptions options,
+ Action onOutput,
+ Action onStreamTerminated,
+ Action onReinitFailed)
+ {
+ if (_isReinitializing)
+ return;
+
+ _isReinitializing = true;
+ try
+ {
+ _log.Warning("Reinitializing {0} session pipeline: {1}", _materializerNamePrefix, reason);
+
+ _inputQueue?.TryComplete();
+ _inputQueue = null;
+
+ if (_session is not null)
+ {
+ await _session.DisposeAsync();
+ _session = null;
+ }
+
+ _materializer?.Dispose();
+ _materializer = null;
+
+ await InitializeWithChannelAsync(context, sessionId, options, onOutput, onStreamTerminated);
+ }
+ catch (Exception ex)
+ {
+ _log.Error(ex, "{0} pipeline reinitialization failed; scheduling retry", _materializerNamePrefix);
+ onReinitFailed();
+ }
+ finally
+ {
+ _isReinitializing = false;
+ }
+ }
+
+ ///
+ /// Synchronous cleanup for actor PostStop. Completes input queue,
+ /// disposes session and materializer.
+ ///
+ public ValueTask DisposeAsync()
+ {
+ _inputQueue?.TryComplete();
+
+ try
+ {
+ _session?.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+ catch
+ {
+ // Best-effort cleanup during actor shutdown
+ }
+
+ _materializer?.Dispose();
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Netclaw.Actors/Reminders/ReminderIdGenerator.cs b/src/Netclaw.Actors/Reminders/ReminderIdGenerator.cs
index 0e19b8dd..392b4c44 100644
--- a/src/Netclaw.Actors/Reminders/ReminderIdGenerator.cs
+++ b/src/Netclaw.Actors/Reminders/ReminderIdGenerator.cs
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
+using Netclaw.Configuration;
namespace Netclaw.Actors.Reminders;
@@ -29,7 +30,7 @@ public static string Normalize(string id)
public static ReminderId Generate(string title)
{
var slug = Normalize(title);
- var suffix = Guid.NewGuid().ToString("N")[..6];
+ var suffix = IdGen.Suffix();
return new ReminderId($"{slug}-{suffix}");
}
}
diff --git a/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs b/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs
index b7a181b3..eb9d27dc 100644
--- a/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs
+++ b/src/Netclaw.Actors/Reminders/ReminderManagerActor.cs
@@ -526,22 +526,19 @@ private async Task HandleExecutionCompletedAsync(ReminderExecutionCompleted comp
FailurePauseThreshold,
completed.ErrorMessage);
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "reminder.execution.failed",
- Category = AlertType.ReminderExecutionFailed,
- Summary = $"Reminder '{title}' execution failed: {completed.ErrorMessage}",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = completed.Id.Value,
- Context = new Dictionary
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "reminder.execution.failed",
+ category: AlertType.ReminderExecutionFailed,
+ summary: $"Reminder '{title}' execution failed: {completed.ErrorMessage}",
+ severity: "warning",
+ source: completed.Id.Value,
+ context: new Dictionary
{
["reminderId"] = completed.Id.Value,
["title"] = title,
["error"] = completed.ErrorMessage ?? "unknown",
- }
- });
+ }));
if (count >= FailurePauseThreshold)
{
@@ -549,22 +546,19 @@ private async Task HandleExecutionCompletedAsync(ReminderExecutionCompleted comp
completed.Id.Value,
FailurePauseThreshold);
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "reminder.auto_disabled",
- Category = AlertType.ReminderAutoDisabled,
- Summary = $"Reminder '{title}' disabled after {count} consecutive failures",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "critical",
- Source = completed.Id.Value,
- Context = new Dictionary
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "reminder.auto_disabled",
+ category: AlertType.ReminderAutoDisabled,
+ summary: $"Reminder '{title}' disabled after {count} consecutive failures",
+ severity: "critical",
+ source: completed.Id.Value,
+ context: new Dictionary
{
["reminderId"] = completed.Id.Value,
["title"] = title,
["failureCount"] = count.ToString(),
- }
- });
+ }));
await DisableReminderInternalAsync(completed.Id);
_failureCounts.Remove(completed.Id);
diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs
index c296461e..ee818ff6 100644
--- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs
+++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs
@@ -2921,7 +2921,7 @@ private void BindTurnTelemetry(MessageSource? source)
_activeMessageId = sourceMessageId;
_activeTurnId = source?.TurnId
?? sourceMessageId
- ?? Guid.NewGuid().ToString("N")[..8];
+ ?? IdGen.ShortId();
_activeChannelType = source?.ChannelType;
CrashContextSnapshot.Update(
diff --git a/src/Netclaw.Channels.Slack/SlackChannel.cs b/src/Netclaw.Channels.Slack/SlackChannel.cs
index cf227696..8048e717 100644
--- a/src/Netclaw.Channels.Slack/SlackChannel.cs
+++ b/src/Netclaw.Channels.Slack/SlackChannel.cs
@@ -172,17 +172,14 @@ public async Task StartAsync(CancellationToken cancellationToken)
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "channel.disconnected",
- Category = AlertType.ChannelDisconnected,
- Summary = $"Slack channel failed to connect: {ex.Message}",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = "slack",
- Context = new Dictionary { ["channel"] = "slack" }
- });
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "channel.disconnected",
+ category: AlertType.ChannelDisconnected,
+ summary: $"Slack channel failed to connect: {ex.Message}",
+ severity: "warning",
+ source: "slack",
+ context: new Dictionary { ["channel"] = "slack" }));
throw;
}
}
diff --git a/src/Netclaw.Channels.Slack/SlackConversationActor.cs b/src/Netclaw.Channels.Slack/SlackConversationActor.cs
index 6a5274cb..a808d44d 100644
--- a/src/Netclaw.Channels.Slack/SlackConversationActor.cs
+++ b/src/Netclaw.Channels.Slack/SlackConversationActor.cs
@@ -3,6 +3,7 @@
using Netclaw.Actors.Channels;
using Netclaw.Actors.Protocol;
using Netclaw.Channels.Telemetry;
+using Netclaw.Configuration;
namespace Netclaw.Channels.Slack;
@@ -106,7 +107,7 @@ public SlackConversationActor(SlackChannelId conversationId, SlackGatewayDepende
var sessionId = new SessionId($"{_conversationId.Value}/{threadTs.Value}");
var turnId = string.IsNullOrWhiteSpace(message.EventId.Value)
- ? Guid.NewGuid().ToString("N")[..8]
+ ? IdGen.ShortId()
: message.EventId.Value;
var log = _log
.WithContext("SlackThreadTs", threadTs.Value)
diff --git a/src/Netclaw.Cli/Doctor/CrashLogHelper.cs b/src/Netclaw.Cli/Doctor/CrashLogHelper.cs
new file mode 100644
index 00000000..2d8315a7
--- /dev/null
+++ b/src/Netclaw.Cli/Doctor/CrashLogHelper.cs
@@ -0,0 +1,48 @@
+using System.Globalization;
+
+namespace Netclaw.Cli.Doctor;
+
+///
+/// Shared crash-log helpers used by
+/// and .
+///
+internal static class CrashLogHelper
+{
+ ///
+ /// Returns true if the daemon's PID file was written after the crash log,
+ /// indicating the daemon has restarted since the crash occurred.
+ ///
+ public static bool IsCrashLogStale(FileInfo crashLog, string pidFilePath)
+ {
+ var pidFile = new FileInfo(pidFilePath);
+ return pidFile.Exists && pidFile.LastWriteTimeUtc > crashLog.LastWriteTimeUtc;
+ }
+
+ ///
+ /// Attempts to extract a UTC timestamp from a crash log filename with the format
+ /// crash-YYYYMMDD-HHMMSS.log (with optional suffixes after the timestamp).
+ /// Returns null if the filename does not match.
+ ///
+ public static DateTimeOffset? TryParseCrashTimestamp(string fileName)
+ {
+ var stem = Path.GetFileNameWithoutExtension(fileName);
+ const string prefix = "crash-";
+ if (!stem.StartsWith(prefix, StringComparison.Ordinal))
+ return null;
+
+ var payload = stem[prefix.Length..];
+ if (payload.Length < 15)
+ return null;
+
+ var timestampPart = payload[..15];
+ if (!DateTimeOffset.TryParseExact(
+ timestampPart,
+ "yyyyMMdd-HHmmss",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal,
+ out var parsed))
+ return null;
+
+ return parsed.ToUniversalTime();
+ }
+}
diff --git a/src/Netclaw.Cli/Doctor/DaemonCrashDoctorCheck.cs b/src/Netclaw.Cli/Doctor/DaemonCrashDoctorCheck.cs
index ee45cd70..18a32d2e 100644
--- a/src/Netclaw.Cli/Doctor/DaemonCrashDoctorCheck.cs
+++ b/src/Netclaw.Cli/Doctor/DaemonCrashDoctorCheck.cs
@@ -23,11 +23,11 @@ public Task RunAsync(CancellationToken cancellationToken = de
}
var latest = recentCrashes[0];
- var occurredAt = TryParseCrashTimestamp(latest.Name)
+ var occurredAt = CrashLogHelper.TryParseCrashTimestamp(latest.Name)
?.ToString("u", CultureInfo.InvariantCulture)
?? latest.LastWriteTimeUtc.ToString("u", CultureInfo.InvariantCulture);
- var restartedSinceLatestCrash = IsCrashLogStale(latest, paths.PidFilePath);
+ var restartedSinceLatestCrash = CrashLogHelper.IsCrashLogStale(latest, paths.PidFilePath);
var restartNote = restartedSinceLatestCrash
? "daemon appears to have restarted since this crash"
: "daemon has not recorded a newer PID timestamp since this crash";
@@ -67,33 +67,4 @@ private static bool IsDaemonCrashLog(FileInfo crashLog)
}
}
- private static bool IsCrashLogStale(FileInfo crashLog, string pidFilePath)
- {
- var pidFile = new FileInfo(pidFilePath);
- return pidFile.Exists && pidFile.LastWriteTimeUtc > crashLog.LastWriteTimeUtc;
- }
-
- private static DateTimeOffset? TryParseCrashTimestamp(string fileName)
- {
- // crash-YYYYMMDD-HHMMSS.log (+ optional suffixes)
- var stem = Path.GetFileNameWithoutExtension(fileName);
- const string prefix = "crash-";
- if (!stem.StartsWith(prefix, StringComparison.Ordinal))
- return null;
-
- var payload = stem[prefix.Length..];
- if (payload.Length < 15)
- return null;
-
- var timestampPart = payload[..15];
- if (!DateTimeOffset.TryParseExact(
- timestampPart,
- "yyyyMMdd-HHmmss",
- CultureInfo.InvariantCulture,
- DateTimeStyles.AssumeUniversal,
- out var parsed))
- return null;
-
- return parsed.ToUniversalTime();
- }
}
diff --git a/src/Netclaw.Cli/Doctor/DoctorFixService.cs b/src/Netclaw.Cli/Doctor/DoctorFixService.cs
index 2ddbe06b..27435b92 100644
--- a/src/Netclaw.Cli/Doctor/DoctorFixService.cs
+++ b/src/Netclaw.Cli/Doctor/DoctorFixService.cs
@@ -40,7 +40,7 @@ public Task BuildPlanAsync(CancellationToken cancellationToken =
appliedFixes.Add("configVersion");
}
- if (obj["Slack"] is JsonObject slack && ReadBool(slack, "Enabled"))
+ if (obj["Slack"] is JsonObject slack && DoctorJsonConfigReader.ReadBool(slack, "Enabled"))
{
var hasAllowedChannels = slack["AllowedChannelIds"] is JsonArray { Count: > 0 };
var hasDefaultChannel = !string.IsNullOrWhiteSpace(slack["DefaultChannelId"]?.GetValue())
@@ -53,7 +53,7 @@ public Task BuildPlanAsync(CancellationToken cancellationToken =
}
}
- if (obj["Telemetry"] is JsonObject telemetry && ReadBool(telemetry, "Enabled"))
+ if (obj["Telemetry"] is JsonObject telemetry && DoctorJsonConfigReader.ReadBool(telemetry, "Enabled"))
{
telemetry["Otlp"] ??= new JsonObject();
if (telemetry["Otlp"] is JsonObject otlp
@@ -149,9 +149,6 @@ public async Task ApplyAsync(DoctorFixPlan plan, CancellationToken cancellationT
foreach (var fix in plan.Fixes)
await File.WriteAllTextAsync(fix.FilePath, fix.UpdatedText, cancellationToken);
}
-
- private static bool ReadBool(JsonObject obj, string property)
- => obj[property] is JsonValue v && v.TryGetValue(out var b) && b;
}
public sealed record DoctorFixPlan(IReadOnlyList Fixes)
diff --git a/src/Netclaw.Cli/Doctor/DoctorJsonConfigReader.cs b/src/Netclaw.Cli/Doctor/DoctorJsonConfigReader.cs
index 7c2580a6..b97731e7 100644
--- a/src/Netclaw.Cli/Doctor/DoctorJsonConfigReader.cs
+++ b/src/Netclaw.Cli/Doctor/DoctorJsonConfigReader.cs
@@ -5,6 +5,28 @@ namespace Netclaw.Cli.Doctor;
internal static class DoctorJsonConfigReader
{
+ ///
+ /// Reads a boolean property from a . Returns false
+ /// if the property is missing, not a boolean, or not true.
+ ///
+ public static bool ReadBool(JsonObject obj, string property)
+ => obj[property] is JsonValue v && v.TryGetValue(out var b) && b;
+
+ ///
+ /// Reads a string array property from a . Returns an empty
+ /// list if the property is missing or not an array.
+ ///
+ public static List ReadStringArray(JsonObject obj, string property)
+ {
+ if (obj[property] is not JsonArray arr)
+ return [];
+
+ return arr.Select(v => v?.GetValue())
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Cast()
+ .ToList();
+ }
+
public static (JsonObject? Root, DoctorCheckResult? Error) TryReadConfig(NetclawPaths paths)
{
if (!File.Exists(paths.NetclawConfigPath))
diff --git a/src/Netclaw.Cli/Doctor/SlackAclDoctorCheck.cs b/src/Netclaw.Cli/Doctor/SlackAclDoctorCheck.cs
index cc7e3c73..2256e879 100644
--- a/src/Netclaw.Cli/Doctor/SlackAclDoctorCheck.cs
+++ b/src/Netclaw.Cli/Doctor/SlackAclDoctorCheck.cs
@@ -11,10 +11,10 @@ public Task RunAsync(CancellationToken cancellationToken = de
if (readError is not null)
return Task.FromResult(DoctorCheckResult.Pass("Slack ACL", "Skipped (base config is missing or invalid)."));
- if (root!["Slack"] is not JsonObject slack || !ReadBool(slack, "Enabled"))
+ if (root!["Slack"] is not JsonObject slack || !DoctorJsonConfigReader.ReadBool(slack, "Enabled"))
return Task.FromResult(DoctorCheckResult.Pass("Slack ACL", "Slack connector disabled or not configured."));
- var hasAllowedChannels = ReadStringArray(slack, "AllowedChannelIds").Count > 0;
+ var hasAllowedChannels = DoctorJsonConfigReader.ReadStringArray(slack, "AllowedChannelIds").Count > 0;
var hasDefaultChannel = !string.IsNullOrWhiteSpace(slack["DefaultChannelId"]?.GetValue())
|| !string.IsNullOrWhiteSpace(slack["DefaultChannelName"]?.GetValue());
@@ -26,8 +26,8 @@ public Task RunAsync(CancellationToken cancellationToken = de
"Set `Slack:AllowedChannelIds` or `Slack:DefaultChannelId`/`Slack:DefaultChannelName`."));
}
- var allowDirectMessages = ReadBool(slack, "AllowDirectMessages");
- var allowedUserIds = ReadStringArray(slack, "AllowedUserIds");
+ var allowDirectMessages = DoctorJsonConfigReader.ReadBool(slack, "AllowDirectMessages");
+ var allowedUserIds = DoctorJsonConfigReader.ReadStringArray(slack, "AllowedUserIds");
if (allowDirectMessages && allowedUserIds.Count == 0)
{
@@ -39,15 +39,4 @@ public Task RunAsync(CancellationToken cancellationToken = de
return Task.FromResult(DoctorCheckResult.Pass("Slack ACL", "Slack channel policy has explicit channel scope."));
}
-
- private static bool ReadBool(JsonObject obj, string property)
- => obj[property] is JsonValue v && v.TryGetValue(out var b) && b;
-
- private static List ReadStringArray(JsonObject obj, string property)
- {
- if (obj[property] is not JsonArray arr)
- return [];
-
- return arr.Select(v => v?.GetValue()).Where(s => !string.IsNullOrWhiteSpace(s)).Cast().ToList();
- }
}
diff --git a/src/Netclaw.Cli/Doctor/SlackAuthDoctorCheck.cs b/src/Netclaw.Cli/Doctor/SlackAuthDoctorCheck.cs
index d79c203d..559dff27 100644
--- a/src/Netclaw.Cli/Doctor/SlackAuthDoctorCheck.cs
+++ b/src/Netclaw.Cli/Doctor/SlackAuthDoctorCheck.cs
@@ -16,7 +16,7 @@ public async Task RunAsync(CancellationToken cancellationToke
if (readError is not null)
return DoctorCheckResult.Pass(CheckName, "Skipped (base config is missing or invalid).");
- if (root!["Slack"] is not JsonObject slack || !ReadBool(slack, "Enabled"))
+ if (root!["Slack"] is not JsonObject slack || !DoctorJsonConfigReader.ReadBool(slack, "Enabled"))
return DoctorCheckResult.Pass(CheckName, "Slack is disabled.");
// Read bot token from secrets.json
@@ -73,7 +73,4 @@ private static (string? Token, string? Error) ReadBotToken(NetclawPaths paths)
return (null, $"Failed reading Slack bot token from secrets.json: {ex.Message}");
}
}
-
- private static bool ReadBool(JsonObject obj, string property)
- => obj[property] is JsonValue v && v.TryGetValue(out var b) && b;
}
diff --git a/src/Netclaw.Cli/Doctor/SqliteProvisioningDoctorCheck.cs b/src/Netclaw.Cli/Doctor/SqliteProvisioningDoctorCheck.cs
index ac862259..afb598df 100644
--- a/src/Netclaw.Cli/Doctor/SqliteProvisioningDoctorCheck.cs
+++ b/src/Netclaw.Cli/Doctor/SqliteProvisioningDoctorCheck.cs
@@ -46,14 +46,14 @@ public Task RunAsync(CancellationToken cancellationToken = de
}
// If the daemon was restarted after the crash, the crash log is stale.
- if (IsCrashLogStale(latestCrash, paths.PidFilePath))
+ if (CrashLogHelper.IsCrashLogStale(latestCrash, paths.PidFilePath))
{
return Task.FromResult(DoctorCheckResult.Pass(
CheckName,
$"SQLite crash detected ({latestCrash.Name}) but daemon has been restarted since."));
}
- var occurredAt = TryParseCrashTimestamp(latestCrash.Name)
+ var occurredAt = CrashLogHelper.TryParseCrashTimestamp(latestCrash.Name)
?.ToString("u", CultureInfo.InvariantCulture)
?? latestCrash.LastWriteTimeUtc.ToString("u", CultureInfo.InvariantCulture);
@@ -100,33 +100,4 @@ private static bool LooksLikeSqliteProvisioningFailure(string crashText)
|| crashText.Contains("SchemaMigrator.MigrateAsync", StringComparison.Ordinal);
}
- ///
- /// Returns true if the PID file was written after the crash log, meaning the daemon
- /// was restarted since the crash and the crash log is stale.
- ///
- private static bool IsCrashLogStale(FileInfo crashLog, string pidFilePath)
- {
- var pidFile = new FileInfo(pidFilePath);
- return pidFile.Exists && pidFile.LastWriteTimeUtc > crashLog.LastWriteTimeUtc;
- }
-
- private static DateTimeOffset? TryParseCrashTimestamp(string fileName)
- {
- // crash-YYYYMMDD-HHMMSS.log
- var stem = Path.GetFileNameWithoutExtension(fileName);
- const string prefix = "crash-";
- if (!stem.StartsWith(prefix, StringComparison.Ordinal))
- return null;
-
- var payload = stem[prefix.Length..];
- if (!DateTimeOffset.TryParseExact(
- payload,
- "yyyyMMdd-HHmmss",
- CultureInfo.InvariantCulture,
- DateTimeStyles.AssumeUniversal,
- out var parsed))
- return null;
-
- return parsed.ToUniversalTime();
- }
}
diff --git a/src/Netclaw.Cli/Doctor/TelemetryDoctorCheck.cs b/src/Netclaw.Cli/Doctor/TelemetryDoctorCheck.cs
index bf23a1b3..bc5fc708 100644
--- a/src/Netclaw.Cli/Doctor/TelemetryDoctorCheck.cs
+++ b/src/Netclaw.Cli/Doctor/TelemetryDoctorCheck.cs
@@ -11,7 +11,7 @@ public Task RunAsync(CancellationToken cancellationToken = de
if (readError is not null)
return Task.FromResult(DoctorCheckResult.Pass("Telemetry", "Skipped (base config is missing or invalid)."));
- if (root!["Telemetry"] is not JsonObject telemetry || !ReadBool(telemetry, "Enabled"))
+ if (root!["Telemetry"] is not JsonObject telemetry || !DoctorJsonConfigReader.ReadBool(telemetry, "Enabled"))
return Task.FromResult(DoctorCheckResult.Pass("Telemetry", "Telemetry disabled or not configured."));
var endpoint = telemetry["Otlp"]?["Endpoint"]?.GetValue();
@@ -33,7 +33,4 @@ public Task RunAsync(CancellationToken cancellationToken = de
return Task.FromResult(DoctorCheckResult.Pass("Telemetry", "Telemetry endpoint is valid."));
}
-
- private static bool ReadBool(JsonObject obj, string property)
- => obj[property] is JsonValue v && v.TryGetValue(out var b) && b;
}
diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
index fb88e5a9..6406b3c7 100644
--- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
+++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
@@ -292,7 +292,7 @@ internal async Task ProbeProviderAsync()
_probeCts = new CancellationTokenSource();
var ct = _probeCts.Token;
var providerType = provider.Entry.Type;
- var probeId = Guid.NewGuid().ToString("N")[..8];
+ var probeId = IdGen.ShortId();
var stopwatch = Stopwatch.StartNew();
Exception? probeException = null;
diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
index f3ac22c2..7fe29d1f 100644
--- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
+++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs
@@ -719,7 +719,7 @@ internal async Task ProbeProviderAsync()
_probeCts = new CancellationTokenSource();
var ct = _probeCts.Token;
var providerType = NewProviderType ?? "unknown";
- var probeId = Guid.NewGuid().ToString("N")[..8];
+ var probeId = IdGen.ShortId();
var stopwatch = Stopwatch.StartNew();
Exception? probeException = null;
@@ -856,7 +856,7 @@ p.ConfiguredName is not null &&
return candidate;
}
- return $"my-{type}-{Guid.NewGuid().ToString("N")[..6]}";
+ return $"my-{type}-{IdGen.Suffix()}";
}
private void ClearAddState()
diff --git a/src/Netclaw.Configuration.Tests/IdGenTests.cs b/src/Netclaw.Configuration.Tests/IdGenTests.cs
new file mode 100644
index 00000000..db5fb094
--- /dev/null
+++ b/src/Netclaw.Configuration.Tests/IdGenTests.cs
@@ -0,0 +1,45 @@
+using Xunit;
+
+namespace Netclaw.Configuration.Tests;
+
+public sealed class IdGenTests
+{
+ [Fact]
+ public void AlertId_returns_12_hex_characters()
+ {
+ var id = IdGen.AlertId();
+ Assert.Equal(12, id.Length);
+ Assert.Matches("^[0-9a-f]{12}$", id);
+ }
+
+ [Fact]
+ public void ShortId_returns_8_hex_characters()
+ {
+ var id = IdGen.ShortId();
+ Assert.Equal(8, id.Length);
+ Assert.Matches("^[0-9a-f]{8}$", id);
+ }
+
+ [Fact]
+ public void Suffix_returns_6_hex_characters()
+ {
+ var id = IdGen.Suffix();
+ Assert.Equal(6, id.Length);
+ Assert.Matches("^[0-9a-f]{6}$", id);
+ }
+
+ [Fact]
+ public void Full_returns_32_hex_characters()
+ {
+ var id = IdGen.Full();
+ Assert.Equal(32, id.Length);
+ Assert.Matches("^[0-9a-f]{32}$", id);
+ }
+
+ [Fact]
+ public void Successive_calls_produce_unique_values()
+ {
+ var ids = Enumerable.Range(0, 100).Select(_ => IdGen.AlertId()).ToHashSet();
+ Assert.Equal(100, ids.Count);
+ }
+}
diff --git a/src/Netclaw.Configuration.Tests/OperationalAlertCreateTests.cs b/src/Netclaw.Configuration.Tests/OperationalAlertCreateTests.cs
new file mode 100644
index 00000000..95fdf8c5
--- /dev/null
+++ b/src/Netclaw.Configuration.Tests/OperationalAlertCreateTests.cs
@@ -0,0 +1,59 @@
+using Microsoft.Extensions.Time.Testing;
+using Xunit;
+
+namespace Netclaw.Configuration.Tests;
+
+public sealed class OperationalAlertCreateTests
+{
+ [Fact]
+ public void Create_sets_all_properties_correctly()
+ {
+ var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 16, 12, 0, 0, TimeSpan.Zero));
+ var context = new Dictionary { ["error"] = "timeout" };
+
+ var alert = OperationalAlert.Create(
+ fakeTime,
+ type: "provider.unreachable",
+ category: AlertType.ProviderUnreachable,
+ summary: "LLM provider unreachable",
+ severity: "critical",
+ source: "anthropic",
+ context: context);
+
+ Assert.Equal("provider.unreachable", alert.Type);
+ Assert.Equal(AlertType.ProviderUnreachable, alert.Category);
+ Assert.Equal("LLM provider unreachable", alert.Summary);
+ Assert.Equal("critical", alert.Severity);
+ Assert.Equal("anthropic", alert.Source);
+ Assert.Same(context, alert.Context);
+ Assert.Equal(fakeTime.GetUtcNow(), alert.Timestamp);
+ }
+
+ [Fact]
+ public void Create_generates_12_char_AlertId()
+ {
+ var alert = OperationalAlert.Create(
+ TimeProvider.System,
+ type: "test.alert",
+ category: AlertType.DaemonStarted,
+ summary: "Test",
+ severity: "info");
+
+ Assert.Equal(12, alert.AlertId.Length);
+ Assert.Matches("^[0-9a-f]{12}$", alert.AlertId);
+ }
+
+ [Fact]
+ public void Create_defaults_source_and_context_to_null()
+ {
+ var alert = OperationalAlert.Create(
+ TimeProvider.System,
+ type: "test.alert",
+ category: AlertType.DaemonStarted,
+ summary: "Test",
+ severity: "info");
+
+ Assert.Null(alert.Source);
+ Assert.Null(alert.Context);
+ }
+}
diff --git a/src/Netclaw.Configuration/IdGen.cs b/src/Netclaw.Configuration/IdGen.cs
new file mode 100644
index 00000000..cb6a2d28
--- /dev/null
+++ b/src/Netclaw.Configuration/IdGen.cs
@@ -0,0 +1,20 @@
+namespace Netclaw.Configuration;
+
+///
+/// Centralized ID generation with purpose-named methods to replace scattered
+/// Guid.NewGuid().ToString("N")[..N] patterns across the codebase.
+///
+public static class IdGen
+{
+ /// 12-character alert ID for .
+ public static string AlertId() => Guid.NewGuid().ToString("N")[..12];
+
+ /// 8-character short ID for turn IDs, message IDs, and probe IDs.
+ public static string ShortId() => Guid.NewGuid().ToString("N")[..8];
+
+ /// 6-character suffix for reminder IDs and similar short suffixes.
+ public static string Suffix() => Guid.NewGuid().ToString("N")[..6];
+
+ /// Full 32-character hex string for state tokens, temp directories, and other non-truncated uses.
+ public static string Full() => Guid.NewGuid().ToString("N");
+}
diff --git a/src/Netclaw.Configuration/OperationalAlert.cs b/src/Netclaw.Configuration/OperationalAlert.cs
index d8de66fb..4bbda9b8 100644
--- a/src/Netclaw.Configuration/OperationalAlert.cs
+++ b/src/Netclaw.Configuration/OperationalAlert.cs
@@ -62,4 +62,27 @@ public sealed record OperationalAlert
/// Deduplication key built from Type and Source.
///
public string DeduplicationKey => Source is not null ? $"{Type}:{Source}" : Type;
+
+ ///
+ /// Creates an with a generated
+ /// and current timestamp from the provided .
+ ///
+ public static OperationalAlert Create(
+ TimeProvider timeProvider,
+ string type,
+ AlertType category,
+ string summary,
+ string severity,
+ string? source = null,
+ Dictionary? context = null) => new()
+ {
+ AlertId = IdGen.AlertId(),
+ Type = type,
+ Category = category,
+ Summary = summary,
+ Timestamp = timeProvider.GetUtcNow(),
+ Severity = severity,
+ Source = source,
+ Context = context
+ };
}
diff --git a/src/Netclaw.Daemon/Configuration/AlertingChatClientDecorator.cs b/src/Netclaw.Daemon/Configuration/AlertingChatClientDecorator.cs
index aa05b6f0..fbe449cb 100644
--- a/src/Netclaw.Daemon/Configuration/AlertingChatClientDecorator.cs
+++ b/src/Netclaw.Daemon/Configuration/AlertingChatClientDecorator.cs
@@ -74,16 +74,13 @@ private async IAsyncEnumerable StreamWithAlertingAsync(
private void EmitUnreachableAlert(Exception ex)
{
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "provider.unreachable",
- Category = AlertType.ProviderUnreachable,
- Summary = "LLM provider unreachable — no fallback configured",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "critical",
- Context = new Dictionary { ["error"] = ex.Message }
- });
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "provider.unreachable",
+ category: AlertType.ProviderUnreachable,
+ summary: "LLM provider unreachable — no fallback configured",
+ severity: "critical",
+ context: new Dictionary { ["error"] = ex.Message }));
}
public object? GetService(Type serviceType, object? serviceKey = null)
diff --git a/src/Netclaw.Daemon/Configuration/FailoverChatClient.cs b/src/Netclaw.Daemon/Configuration/FailoverChatClient.cs
index af518e78..a4a28e5e 100644
--- a/src/Netclaw.Daemon/Configuration/FailoverChatClient.cs
+++ b/src/Netclaw.Daemon/Configuration/FailoverChatClient.cs
@@ -166,30 +166,24 @@ private async IAsyncEnumerable StreamWithFailoverAsync(
private void EmitFailoverAlert(Exception ex)
{
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "provider.failover",
- Category = AlertType.ProviderFailover,
- Summary = "Primary LLM provider failed, failing over to fallback",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Context = new Dictionary { ["error"] = ex.Message }
- });
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "provider.failover",
+ category: AlertType.ProviderFailover,
+ summary: "Primary LLM provider failed, failing over to fallback",
+ severity: "warning",
+ context: new Dictionary { ["error"] = ex.Message }));
}
private void EmitUnreachableAlert(Exception ex)
{
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "provider.unreachable",
- Category = AlertType.ProviderUnreachable,
- Summary = "All LLM providers failed — primary and fallback both unreachable",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "critical",
- Context = new Dictionary { ["error"] = ex.Message }
- });
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "provider.unreachable",
+ category: AlertType.ProviderUnreachable,
+ summary: "All LLM providers failed — primary and fallback both unreachable",
+ severity: "critical",
+ context: new Dictionary { ["error"] = ex.Message }));
}
public object? GetService(Type serviceType, object? serviceKey = null)
diff --git a/src/Netclaw.Daemon/Mcp/McpClientManager.cs b/src/Netclaw.Daemon/Mcp/McpClientManager.cs
index f129b895..8a39ccea 100644
--- a/src/Netclaw.Daemon/Mcp/McpClientManager.cs
+++ b/src/Netclaw.Daemon/Mcp/McpClientManager.cs
@@ -606,36 +606,30 @@ internal static McpServerStatus CreateUnreachableStatus(McpServerName serverName
private void EmitAuthAlert(McpServerName serverName, string summary, string reason)
{
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "mcp.auth.expired",
- Category = AlertType.McpAuthExpired,
- Summary = summary,
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = serverName.Value,
- Context = new Dictionary
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "mcp.auth.expired",
+ category: AlertType.McpAuthExpired,
+ summary: summary,
+ severity: "warning",
+ source: serverName.Value,
+ context: new Dictionary
{
["serverName"] = serverName.Value,
["reason"] = reason,
- }
- });
+ }));
}
private void EmitDisconnectedAlert(McpServerName serverName, string summary)
{
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "mcp.server.disconnected",
- Category = AlertType.McpServerDisconnected,
- Summary = summary,
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = serverName.Value,
- Context = new Dictionary { ["serverName"] = serverName.Value }
- });
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "mcp.server.disconnected",
+ category: AlertType.McpServerDisconnected,
+ summary: summary,
+ severity: "warning",
+ source: serverName.Value,
+ context: new Dictionary { ["serverName"] = serverName.Value }));
}
private static IEnumerable? ParseScopes(string? scopeString)
diff --git a/src/Netclaw.Daemon/Mcp/McpOAuthService.cs b/src/Netclaw.Daemon/Mcp/McpOAuthService.cs
index 4c0af6ee..76279ee8 100644
--- a/src/Netclaw.Daemon/Mcp/McpOAuthService.cs
+++ b/src/Netclaw.Daemon/Mcp/McpOAuthService.cs
@@ -346,21 +346,18 @@ public async Task CompleteAuthorizationAsync(string code, string state, Cancella
{
_logger.LogWarning("Access token expired for MCP server '{Name}' with no refresh token", serverName.Value);
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "mcp.auth.expired",
- Category = AlertType.McpAuthExpired,
- Summary = $"MCP server '{serverName.Value}' access token expired with no refresh token. Run: netclaw mcp auth {serverName.Value}",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = serverName.Value,
- Context = new Dictionary
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "mcp.auth.expired",
+ category: AlertType.McpAuthExpired,
+ summary: $"MCP server '{serverName.Value}' access token expired with no refresh token. Run: netclaw mcp auth {serverName.Value}",
+ severity: "warning",
+ source: serverName.Value,
+ context: new Dictionary
{
["serverName"] = serverName.Value,
["reason"] = "no_refresh_token",
- }
- });
+ }));
return null;
}
@@ -404,21 +401,18 @@ public async Task CompleteAuthorizationAsync(string code, string state, Cancella
_tokens.TryRemove(serverName, out _);
PersistTokens();
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "mcp.auth.expired",
- Category = AlertType.McpAuthExpired,
- Summary = $"MCP server '{serverName.Value}' refresh token rejected (invalid_grant). Run: netclaw mcp auth {serverName.Value}",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = serverName.Value,
- Context = new Dictionary
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "mcp.auth.expired",
+ category: AlertType.McpAuthExpired,
+ summary: $"MCP server '{serverName.Value}' refresh token rejected (invalid_grant). Run: netclaw mcp auth {serverName.Value}",
+ severity: "warning",
+ source: serverName.Value,
+ context: new Dictionary
{
["serverName"] = serverName.Value,
["reason"] = "invalid_grant",
- }
- });
+ }));
return null;
}
diff --git a/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs b/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs
index 23ef8db9..a7106eab 100644
--- a/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs
+++ b/src/Netclaw.Daemon/Services/BinaryUpdateCheckService.cs
@@ -91,20 +91,17 @@ internal async Task CheckAndNotifyAsync(CancellationToken cancellationToken)
private void EmitUpdateAlert(UpdateCheckResult result)
{
- _notificationSink?.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N"),
- Type = "update.available",
- Category = AlertType.UpdateAvailable,
- Summary = $"Netclaw update available: {result.CurrentVersion} → {result.LatestVersion}. Run 'netclaw update' to upgrade.",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "info",
- Source = result.LatestVersion,
- Context = new Dictionary
+ _notificationSink?.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "update.available",
+ category: AlertType.UpdateAvailable,
+ summary: $"Netclaw update available: {result.CurrentVersion} → {result.LatestVersion}. Run 'netclaw update' to upgrade.",
+ severity: "info",
+ source: result.LatestVersion,
+ context: new Dictionary
{
["currentVersion"] = result.CurrentVersion,
["latestVersion"] = result.LatestVersion,
- },
- });
+ }));
}
}
diff --git a/src/Netclaw.Daemon/Services/DaemonLifecycleNotifier.cs b/src/Netclaw.Daemon/Services/DaemonLifecycleNotifier.cs
index 14194e5f..edd26a83 100644
--- a/src/Netclaw.Daemon/Services/DaemonLifecycleNotifier.cs
+++ b/src/Netclaw.Daemon/Services/DaemonLifecycleNotifier.cs
@@ -33,20 +33,17 @@ public void NotifyStarted()
var pid = Environment.ProcessId;
_logger.LogInformation("Netclaw daemon started (PID {Pid})", pid);
- _sink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "daemon.started",
- Category = AlertType.DaemonStarted,
- Severity = "info",
- Summary = "Netclaw daemon started",
- Timestamp = _timeProvider.GetUtcNow(),
- Source = pid.ToString(CultureInfo.InvariantCulture),
- Context = new Dictionary
+ _sink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "daemon.started",
+ category: AlertType.DaemonStarted,
+ summary: "Netclaw daemon started",
+ severity: "info",
+ source: pid.ToString(CultureInfo.InvariantCulture),
+ context: new Dictionary
{
["pid"] = pid.ToString(CultureInfo.InvariantCulture),
- },
- });
+ }));
}
///
@@ -72,17 +69,14 @@ public void NotifyShutdown(string reason, IReadOnlyDictionary? a
context[pair.Key] = pair.Value;
}
- _sink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "daemon.stopping",
- Category = AlertType.DaemonStopping,
- Severity = "info",
- Summary = $"Netclaw daemon stopping: {reason}",
- Timestamp = _timeProvider.GetUtcNow(),
- Source = pid.ToString(CultureInfo.InvariantCulture),
- Context = context,
- });
+ _sink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "daemon.stopping",
+ category: AlertType.DaemonStopping,
+ summary: $"Netclaw daemon stopping: {reason}",
+ severity: "info",
+ source: pid.ToString(CultureInfo.InvariantCulture),
+ context: context));
}
///
@@ -127,17 +121,14 @@ public void NotifyCrashing(
try
{
- _sink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "daemon.crashing",
- Category = AlertType.DaemonCrashed,
- Severity = "critical",
- Summary = $"Netclaw daemon crashing: {reason} ({exceptionType})",
- Timestamp = _timeProvider.GetUtcNow(),
- Source = pid.ToString(CultureInfo.InvariantCulture),
- Context = context,
- });
+ _sink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "daemon.crashing",
+ category: AlertType.DaemonCrashed,
+ summary: $"Netclaw daemon crashing: {reason} ({exceptionType})",
+ severity: "critical",
+ source: pid.ToString(CultureInfo.InvariantCulture),
+ context: context));
}
catch (Exception ex)
{
diff --git a/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs
index 37326557..acd0e701 100644
--- a/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs
+++ b/src/Netclaw.Daemon/Webhooks/WebhookEndpointRouteBuilderExtensions.cs
@@ -127,23 +127,20 @@ public static IEndpointRouteBuilder MapWebhookEndpoints(this IEndpointRouteBuild
"Webhook accepted route={Route} reason={Reason} remote_ip={RemoteIp} delivery_id={DeliveryId} event_type={EventType}",
registeredRoute.Name, "accepted", remoteIp, verification.DeliveryId, verification.EventType);
- notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "webhook.received",
- Category = AlertType.WebhookReceived,
- Summary = $"Webhook '{registeredRoute.Name}' received event '{verification.EventType ?? "unknown"}'",
- Timestamp = now,
- Severity = "info",
- Source = registeredRoute.Name,
- Context = new Dictionary
+ notificationSink.Emit(OperationalAlert.Create(
+ timeProvider,
+ type: "webhook.received",
+ category: AlertType.WebhookReceived,
+ summary: $"Webhook '{registeredRoute.Name}' received event '{verification.EventType ?? "unknown"}'",
+ severity: "info",
+ source: registeredRoute.Name,
+ context: new Dictionary
{
["route"] = registeredRoute.Name,
["event"] = verification.EventType ?? "unknown",
["deliveryId"] = verification.DeliveryId ?? "generated",
["sessionId"] = sessionId.Value,
- }
- });
+ }));
executionService.StartInvocation(invocation);
return Results.Json(
diff --git a/src/Netclaw.Daemon/Webhooks/WebhookRouteCatalog.cs b/src/Netclaw.Daemon/Webhooks/WebhookRouteCatalog.cs
index 72779a3f..fa55a166 100644
--- a/src/Netclaw.Daemon/Webhooks/WebhookRouteCatalog.cs
+++ b/src/Netclaw.Daemon/Webhooks/WebhookRouteCatalog.cs
@@ -236,22 +236,19 @@ private void RemoveRoute(string routeName, string reason, string filePath, bool
if (!emitAlert)
return;
- _notificationSink.Emit(new OperationalAlert
- {
- AlertId = Guid.NewGuid().ToString("N")[..12],
- Type = "webhook.route.invalid",
- Category = AlertType.WebhookRouteInvalid,
- Summary = $"Webhook route '{routeName}' is unavailable: {reason}",
- Timestamp = _timeProvider.GetUtcNow(),
- Severity = "warning",
- Source = routeName,
- Context = new Dictionary
+ _notificationSink.Emit(OperationalAlert.Create(
+ _timeProvider,
+ type: "webhook.route.invalid",
+ category: AlertType.WebhookRouteInvalid,
+ summary: $"Webhook route '{routeName}' is unavailable: {reason}",
+ severity: "warning",
+ source: routeName,
+ context: new Dictionary
{
["route"] = routeName,
["file"] = filePath,
["reason"] = reason,
- }
- });
+ }));
}
private string GetRouteFilePath(string routeName)