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)