Files
live_recorder/src/LiveRecorder.Infrastructure/Services/EventScriptService.cs
T

858 lines
30 KiB
C#

using System.Diagnostics;
using System.Text;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Common;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class EventScriptService : IEventScriptService
{
private const string ScriptLogPathEnvironmentVariable = "LIVE_RECORDER_SCRIPT_LOG_PATH";
private const int SystemLogDetailMaxLength = 4000;
private const string TruncatedDetailSuffix = "... [truncated to fit system log detail limit]";
private readonly ISystemSettingsService _settingsService;
private readonly ISystemLogService _systemLogService;
private readonly IEmailNotificationService _emailNotificationService;
private readonly IWebhookNotificationService _webhookNotificationService;
private readonly ILogger<EventScriptService> _logger;
public EventScriptService(
ISystemSettingsService settingsService,
ISystemLogService systemLogService,
IEmailNotificationService emailNotificationService,
IWebhookNotificationService webhookNotificationService,
ILogger<EventScriptService> logger)
{
_settingsService = settingsService;
_systemLogService = systemLogService;
_emailNotificationService = emailNotificationService;
_webhookNotificationService = webhookNotificationService;
_logger = logger;
}
public async Task<EventScriptExecutionResultDto?> RunLiveStartedAsync(
LiveRoom liveRoom,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "live_started";
return await RunAsync(
settings.EnableEventScripts && settings.EnableLiveStartedScript,
settings.LiveStartedScriptMode,
settings.LiveStartedScriptPath,
settings.LiveStartedScriptContent,
settings.EventScriptTimeoutSeconds,
settings.EventScriptRetryAttempts,
settings.EventScriptRetryDelaySeconds,
"live_started",
environment,
liveRoom,
recordTask: null,
"Script",
liveRoom.Id,
recordSessionId: null,
recordTaskId: null,
cancellationToken);
}
public async Task<EventScriptExecutionResultDto?> RunLiveEndedAsync(
LiveRoom liveRoom,
DateTimeOffset occurredAt,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "live_ended";
return await RunAsync(
settings.EnableEventScripts && settings.EnableLiveEndedScript,
settings.LiveEndedScriptMode,
settings.LiveEndedScriptPath,
settings.LiveEndedScriptContent,
settings.EventScriptTimeoutSeconds,
settings.EventScriptRetryAttempts,
settings.EventScriptRetryDelaySeconds,
"live_ended",
environment,
liveRoom,
recordTask: null,
"Script",
liveRoom.Id,
recordSessionId: null,
recordTaskId: null,
cancellationToken);
}
public async Task<EventScriptExecutionResultDto?> RunSegmentCompletedAsync(
LiveRoom? liveRoom,
RecordSession recordSession,
RecordTask recordTask,
RecordResult? recordResult,
string segmentFilePath,
DateTimeOffset occurredAt,
bool forceRun = false,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync(cancellationToken);
var environment = BuildLiveRoomEnvironment(liveRoom, occurredAt);
environment["LIVE_RECORDER_EVENT"] = "segment_completed";
environment["LIVE_RECORDER_RECORD_SESSION_ID"] = recordSession.Id.ToString();
environment["LIVE_RECORDER_RECORD_TASK_ID"] = recordTask.Id.ToString();
environment["LIVE_RECORDER_SEGMENT_INDEX"] = recordTask.SegmentIndex.ToString();
environment["LIVE_RECORDER_SEGMENT_FILE_PATH"] = NormalizePath(segmentFilePath);
environment["LIVE_RECORDER_DANMAKU_FILE_PATH"] = NormalizePath(recordResult?.DanmakuFilePath);
environment["LIVE_RECORDER_DURATION_SECONDS"] = recordResult?.DurationSeconds?.ToString("0.###") ?? recordTask.DurationSeconds?.ToString("0.###") ?? string.Empty;
environment["LIVE_RECORDER_FILE_SIZE_BYTES"] = recordResult?.FileSizeBytes?.ToString() ?? TryGetFileSize(segmentFilePath);
environment["LIVE_RECORDER_TASK_STATUS"] = recordTask.Status.ToString();
environment["LIVE_RECORDER_SESSION_STATUS"] = recordSession.Status.ToString();
return await RunAsync(
forceRun || (settings.EnableEventScripts && settings.EnableSegmentCompletedScript),
settings.SegmentCompletedScriptMode,
settings.SegmentCompletedScriptPath,
settings.SegmentCompletedScriptContent,
settings.EventScriptTimeoutSeconds,
settings.EventScriptRetryAttempts,
settings.EventScriptRetryDelaySeconds,
"segment_completed",
environment,
liveRoom,
recordTask,
"Script",
liveRoom?.Id ?? recordSession.LiveRoomId,
recordSession.Id,
recordTask.Id,
cancellationToken);
}
public async Task<EventScriptTestResultDto> TestAsync(
TestEventScriptRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var eventName = NormalizeEventType(request.EventType);
if (eventName is null)
{
var result = new EventScriptTestResultDto
{
Success = false,
Message = "Unsupported event script test type.",
Detail = request.EventType
};
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"ScriptTest",
result.Message,
result.Detail,
cancellationToken: cancellationToken);
return result;
}
var outcome = await ExecuteAsync(
request.ScriptMode,
request.ScriptPath,
request.ScriptContent,
request.TimeoutSeconds,
eventName,
BuildTestEnvironment(eventName, DateTimeOffset.UtcNow),
"ScriptTest",
liveRoomId: null,
recordSessionId: null,
recordTaskId: null,
cancellationToken);
return new EventScriptTestResultDto
{
Success = outcome.Success,
Message = outcome.Message,
Detail = outcome.Detail,
CustomLogOutput = outcome.CustomLogOutput
};
}
private static EventScriptExecutionResultDto MapOutcome(ScriptExecutionOutcome outcome) => new()
{
Success = outcome.Success,
Message = outcome.Message,
Detail = outcome.Detail,
CustomLogOutput = outcome.CustomLogOutput
};
private async Task<EventScriptExecutionResultDto?> RunAsync(
bool enabled,
string scriptMode,
string scriptPath,
string scriptContent,
int timeoutSeconds,
int retryAttempts,
int retryDelaySeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
LiveRoom? liveRoom,
RecordTask? recordTask,
string logCategory,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
if (!enabled)
{
return null;
}
var outcome = await ExecuteWithRetryAsync(
scriptMode,
scriptPath,
scriptContent,
timeoutSeconds,
retryAttempts,
retryDelaySeconds,
eventName,
environment,
liveRoom,
recordTask,
logCategory,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return MapOutcome(outcome);
}
private async Task<ScriptExecutionOutcome> ExecuteWithRetryAsync(
string scriptMode,
string scriptPath,
string scriptContent,
int timeoutSeconds,
int retryAttempts,
int retryDelaySeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
LiveRoom? liveRoom,
RecordTask? recordTask,
string logCategory,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
var outcome = await ExecuteAsync(
scriptMode,
scriptPath,
scriptContent,
timeoutSeconds,
eventName,
environment,
logCategory,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
if (outcome.Success || !IsRetryableFailure(outcome))
{
return outcome;
}
var maxRetryAttempts = Math.Clamp(retryAttempts, 0, 20);
var totalAttempts = 1;
for (var retryIndex = 1; retryIndex <= maxRetryAttempts; retryIndex++)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
logCategory,
$"Event script retry {retryIndex} of {maxRetryAttempts} scheduled for {eventName}.",
BuildRetryAttemptDetail(eventName, retryIndex + 1, maxRetryAttempts + 1, retryDelaySeconds, outcome),
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
if (retryDelaySeconds > 0)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(retryDelaySeconds, 0, 3600)), cancellationToken);
}
outcome = await ExecuteAsync(
scriptMode,
scriptPath,
scriptContent,
timeoutSeconds,
eventName,
environment,
logCategory,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
totalAttempts++;
if (outcome.Success || !IsRetryableFailure(outcome))
{
return outcome;
}
}
await NotifyRetryExhaustedAsync(eventName, totalAttempts, outcome, liveRoom, recordTask, cancellationToken);
return outcome;
}
private async Task<ScriptExecutionOutcome> ExecuteAsync(
string scriptMode,
string scriptPath,
string scriptContent,
int timeoutSeconds,
string eventName,
IReadOnlyDictionary<string, string> environment,
string logCategory,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
var execution = CreateExecution(scriptMode, scriptPath, scriptContent);
if (execution is null)
{
var missingConfiguration = new ScriptExecutionOutcome(
false,
$"Event script was not configured for {eventName}.",
null,
null,
null,
ScriptFailureKind.MissingConfiguration);
await WriteOutcomeLogAsync(
missingConfiguration,
logCategory,
SystemLogLevel.Warning,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return missingConfiguration;
}
if (execution.IsMissing)
{
var missingScript = new ScriptExecutionOutcome(
false,
$"Event script was not found for {eventName}.",
execution.Detail,
null,
execution.Detail,
ScriptFailureKind.MissingScript);
await WriteOutcomeLogAsync(
missingScript,
logCategory,
SystemLogLevel.Warning,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return missingScript;
}
var startInfo = execution.StartInfo!;
foreach (var pair in environment)
{
startInfo.Environment[pair.Key] = pair.Value;
}
var scriptLogPath = CreateScriptLogPath();
try
{
await File.WriteAllTextAsync(scriptLogPath, string.Empty, CancellationToken.None);
startInfo.Environment[ScriptLogPathEnvironmentVariable] = scriptLogPath;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prepare event script log file for {EventName}", eventName);
}
using var process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = true
};
ScriptExecutionOutcome outcome;
SystemLogLevel outcomeLevel;
try
{
process.Start();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 3600)));
await process.WaitForExitAsync(timeoutCts.Token);
outcome = new ScriptExecutionOutcome(
process.ExitCode == 0,
process.ExitCode == 0
? $"Event script completed for {eventName}."
: $"Event script exited with code {process.ExitCode} for {eventName}.",
execution.Detail,
null,
execution.Detail,
process.ExitCode == 0 ? ScriptFailureKind.None : ScriptFailureKind.ExitCode);
outcomeLevel = process.ExitCode == 0 ? SystemLogLevel.Info : SystemLogLevel.Warning;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
TryKill(process);
outcome = new ScriptExecutionOutcome(
false,
$"Event script timed out for {eventName}.",
execution.Detail,
null,
execution.Detail,
ScriptFailureKind.Timeout);
outcomeLevel = SystemLogLevel.Warning;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Event script failed for {EventName}", eventName);
outcome = new ScriptExecutionOutcome(
false,
$"Event script failed for {eventName}.",
ex.ToString(),
null,
execution.Detail,
ScriptFailureKind.Exception);
outcomeLevel = SystemLogLevel.Warning;
}
finally
{
// Any script-provided text is persisted and surfaced separately from the execution outcome.
}
var customLogOutput = await TryWriteCustomLogOutputAsync(
eventName,
logCategory,
scriptLogPath,
liveRoomId,
recordSessionId,
recordTaskId);
TryDeleteFile(scriptLogPath);
outcome = outcome with { CustomLogOutput = customLogOutput };
await WriteOutcomeLogAsync(
outcome,
logCategory,
outcomeLevel,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
return outcome;
}
private async Task NotifyRetryExhaustedAsync(
string eventName,
int totalAttempts,
ScriptExecutionOutcome outcome,
LiveRoom? liveRoom,
RecordTask? recordTask,
CancellationToken cancellationToken)
{
var detail = BuildRetryFailureNotificationDetail(eventName, totalAttempts, outcome);
await _emailNotificationService.SendExceptionAsync(
"EventScript",
"Event script failed after retries.",
detail,
liveRoom,
recordTask,
cancellationToken);
await _webhookNotificationService.SendExceptionAsync(
"EventScript",
"Event script failed after retries.",
detail,
liveRoom,
recordTask,
cancellationToken);
}
private static EventScriptExecution? CreateExecution(string scriptMode, string scriptPath, string scriptContent)
{
if (string.Equals(scriptMode, EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(scriptContent))
{
return null;
}
return new EventScriptExecution(CreateInlineStartInfo(scriptContent), "[inline script]");
}
if (string.IsNullOrWhiteSpace(scriptPath))
{
return null;
}
var normalizedScriptPath = NormalizePath(scriptPath);
if (!File.Exists(normalizedScriptPath))
{
return new EventScriptExecution(null, normalizedScriptPath, IsMissing: true);
}
return new EventScriptExecution(CreateStartInfo(normalizedScriptPath), normalizedScriptPath);
}
private static Dictionary<string, string> BuildLiveRoomEnvironment(LiveRoom? liveRoom, DateTimeOffset occurredAt)
{
var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt);
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["LIVE_RECORDER_EVENT"] = string.Empty,
["LIVE_RECORDER_PLATFORM"] = liveRoom?.Platform.ToString() ?? string.Empty,
["LIVE_RECORDER_LIVE_ROOM_ID"] = liveRoom?.Id.ToString() ?? string.Empty,
["LIVE_RECORDER_ROOM_ID"] = liveRoom?.RoomId ?? string.Empty,
["LIVE_RECORDER_TITLE"] = liveRoom?.Title ?? string.Empty,
["LIVE_RECORDER_ANCHOR"] = liveRoom?.AnchorName ?? string.Empty,
["LIVE_RECORDER_SOURCE_URL"] = liveRoom?.SourceUrl ?? liveRoom?.NormalizedUrl ?? string.Empty,
["LIVE_RECORDER_OCCURRED_AT_UTC"] = localOccurredAt.ToString("O")
};
}
private static IReadOnlyDictionary<string, string> BuildTestEnvironment(string eventName, DateTimeOffset occurredAt)
{
var localOccurredAt = ChinaTime.ToBeijingTime(occurredAt);
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["LIVE_RECORDER_EVENT"] = eventName,
["LIVE_RECORDER_PLATFORM"] = "Douyin",
["LIVE_RECORDER_LIVE_ROOM_ID"] = "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901",
["LIVE_RECORDER_ROOM_ID"] = "676493068539",
["LIVE_RECORDER_TITLE"] = "Sample live title",
["LIVE_RECORDER_ANCHOR"] = "Sample anchor",
["LIVE_RECORDER_SOURCE_URL"] = "https://live.douyin.com/676493068539",
["LIVE_RECORDER_OCCURRED_AT_UTC"] = localOccurredAt.ToString("O")
};
if (string.Equals(eventName, "segment_completed", StringComparison.OrdinalIgnoreCase))
{
environment["LIVE_RECORDER_RECORD_SESSION_ID"] = "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77";
environment["LIVE_RECORDER_RECORD_TASK_ID"] = "2a4810a2-7ef4-4a22-90d4-0211b90cc54c";
environment["LIVE_RECORDER_SEGMENT_INDEX"] = "1";
environment["LIVE_RECORDER_SEGMENT_FILE_PATH"] = "/app/records/Douyin/Sample Anchor/2026-04-25/203000_Sample live title__00001.mp4";
environment["LIVE_RECORDER_DANMAKU_FILE_PATH"] = "/app/records/Douyin/Sample Anchor/2026-04-25/203000_Sample live title__00001.xml";
environment["LIVE_RECORDER_DURATION_SECONDS"] = "2185.1";
environment["LIVE_RECORDER_FILE_SIZE_BYTES"] = "734003200";
environment["LIVE_RECORDER_TASK_STATUS"] = "Completed";
environment["LIVE_RECORDER_SESSION_STATUS"] = "Completed";
}
return environment;
}
private static string? NormalizeEventType(string? eventType)
{
return eventType?.Trim().ToLowerInvariant() switch
{
"live_started" => "live_started",
"live_ended" => "live_ended",
"segment_completed" => "segment_completed",
_ => null
};
}
private static ProcessStartInfo CreateStartInfo(string scriptPath)
{
var extension = Path.GetExtension(scriptPath);
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardError = false,
RedirectStandardOutput = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows() && extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = "powershell";
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-File");
startInfo.ArgumentList.Add(scriptPath);
return startInfo;
}
if (OperatingSystem.IsWindows() && (extension.Equals(".bat", StringComparison.OrdinalIgnoreCase) || extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)))
{
startInfo.FileName = "cmd";
startInfo.ArgumentList.Add("/c");
startInfo.ArgumentList.Add(scriptPath);
return startInfo;
}
if (!OperatingSystem.IsWindows() && extension.Equals(".sh", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = "/bin/sh";
startInfo.ArgumentList.Add(scriptPath);
return startInfo;
}
startInfo.FileName = scriptPath;
return startInfo;
}
private static ProcessStartInfo CreateInlineStartInfo(string scriptContent)
{
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardError = false,
RedirectStandardOutput = false,
CreateNoWindow = true,
WorkingDirectory = AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows())
{
startInfo.FileName = "powershell";
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-Command");
startInfo.ArgumentList.Add(scriptContent);
return startInfo;
}
startInfo.FileName = "/bin/sh";
startInfo.ArgumentList.Add("-c");
startInfo.ArgumentList.Add(scriptContent);
return startInfo;
}
private static string NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
return Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
}
private static string TryGetFileSize(string? path)
{
var normalizedPath = NormalizePath(path);
if (string.IsNullOrWhiteSpace(normalizedPath) || !File.Exists(normalizedPath))
{
return string.Empty;
}
return new FileInfo(normalizedPath).Length.ToString();
}
private async Task<string?> TryWriteCustomLogOutputAsync(
string eventName,
string logCategory,
string scriptLogPath,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId)
{
if (string.IsNullOrWhiteSpace(scriptLogPath) || !File.Exists(scriptLogPath))
{
return null;
}
try
{
var output = await File.ReadAllTextAsync(scriptLogPath, CancellationToken.None);
var trimmedOutput = output.Trim();
if (string.IsNullOrWhiteSpace(trimmedOutput))
{
return null;
}
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
logCategory,
$"Event script emitted custom log output for {eventName}.",
TruncateSystemLogDetail(trimmedOutput),
liveRoomId,
recordSessionId,
recordTaskId,
CancellationToken.None);
return trimmedOutput;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to collect event script custom log output for {EventName}", eventName);
return null;
}
}
private async Task WriteOutcomeLogAsync(
ScriptExecutionOutcome outcome,
string logCategory,
SystemLogLevel level,
Guid? liveRoomId,
Guid? recordSessionId,
Guid? recordTaskId,
CancellationToken cancellationToken)
{
await _systemLogService.WriteAsync(
level,
logCategory,
outcome.Message,
outcome.Detail,
liveRoomId,
recordSessionId,
recordTaskId,
cancellationToken);
}
private static string BuildRetryAttemptDetail(
string eventName,
int nextAttempt,
int totalAttempts,
int retryDelaySeconds,
ScriptExecutionOutcome outcome)
{
var builder = new StringBuilder();
builder.AppendLine($"Event: {eventName}");
builder.AppendLine($"Next attempt: {nextAttempt}/{totalAttempts}");
builder.AppendLine($"Retry delay: {Math.Clamp(retryDelaySeconds, 0, 3600)} second(s)");
builder.AppendLine($"Last result: {outcome.Message}");
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
{
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
}
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
{
builder.AppendLine();
builder.AppendLine("Detail:");
builder.AppendLine(outcome.Detail);
}
return TruncateSystemLogDetail(builder.ToString().Trim());
}
private static string BuildRetryFailureNotificationDetail(
string eventName,
int totalAttempts,
ScriptExecutionOutcome outcome)
{
var builder = new StringBuilder();
builder.AppendLine($"Event: {eventName}");
builder.AppendLine($"Attempts: {totalAttempts}");
builder.AppendLine($"Last result: {outcome.Message}");
if (!string.IsNullOrWhiteSpace(outcome.ExecutionTarget))
{
builder.AppendLine($"Script: {outcome.ExecutionTarget}");
}
if (!string.IsNullOrWhiteSpace(outcome.Detail) &&
!string.Equals(outcome.Detail, outcome.ExecutionTarget, StringComparison.Ordinal))
{
builder.AppendLine();
builder.AppendLine("Detail:");
builder.AppendLine(outcome.Detail);
}
if (!string.IsNullOrWhiteSpace(outcome.CustomLogOutput))
{
builder.AppendLine();
builder.AppendLine("Custom log output:");
builder.AppendLine(outcome.CustomLogOutput);
}
return TruncateSystemLogDetail(builder.ToString().Trim());
}
private static bool IsRetryableFailure(ScriptExecutionOutcome outcome) =>
outcome.FailureKind is ScriptFailureKind.ExitCode or ScriptFailureKind.Timeout or ScriptFailureKind.Exception;
private static string CreateScriptLogPath()
{
return Path.Combine(
Path.GetTempPath(),
$"live-recorder-script-log-{Guid.NewGuid():N}.txt");
}
private static string TruncateSystemLogDetail(string detail)
{
if (detail.Length <= SystemLogDetailMaxLength)
{
return detail;
}
var prefixLength = Math.Max(0, SystemLogDetailMaxLength - TruncatedDetailSuffix.Length);
return string.Concat(detail[..prefixLength], TruncatedDetailSuffix);
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch
{
}
}
private static void TryDeleteFile(string path)
{
try
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
private enum ScriptFailureKind
{
None,
MissingConfiguration,
MissingScript,
ExitCode,
Timeout,
Exception
}
private sealed record EventScriptExecution(ProcessStartInfo? StartInfo, string Detail, bool IsMissing = false);
private sealed record ScriptExecutionOutcome(
bool Success,
string Message,
string? Detail,
string? CustomLogOutput,
string? ExecutionTarget,
ScriptFailureKind FailureKind);
}