XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Text.Json;
using System.Text.Json.Serialization;
using XFEExtension.NetCore.XFEConsole;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Infrastructure.Execution;

namespace XFEWorkflow.Infrastructure.Storage;

public sealed class WorkflowLogEntry : XFELogEntry
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");
    public string Category { get; set; } = "应用";
    public string WorkflowId { get; set; } = "";
    public string WorkflowName { get; set; } = "";
    public string RunId { get; set; } = "";
    public string? NodeId { get; set; }
    public string NodeName { get; set; } = "";
    public string Trigger { get; set; } = "";
    public WorkflowDiagnostic? Diagnostic { get; set; }
    [JsonIgnore] public string TimeText => Time.ToString("HH:mm:ss.fff");
    [JsonIgnore] public string LevelLabel => Level.ToString().ToUpperInvariant();
    [JsonIgnore] public string SourceLabel => WorkflowName.Length == 0 ? Category : WorkflowName + (NodeName.Length == 0 ? "" : " / " + NodeName);
    [JsonIgnore] public string SearchText => $"{LogText} {SourceLabel} {RunId} {NodeId} {Trigger}";
}

/// <summary>Runner events retain their original run record; diagnostics use a separate journal.
/// Both are XFE log entries and share the same filtering, rendering and export pipeline.</summary>
public sealed class WorkflowLogService(WorkspacePaths paths, RunCoordinator runs, SecretRedactor redactor)
{
    private readonly object gate = new();
    private readonly Dictionary<string, (int Count, bool Finished)> observed = [];
    private readonly Dictionary<string, List<WorkflowLogEntry>> live = [];
    private readonly Dictionary<string, string> diagnosticVersions = [];
    private readonly SemaphoreSlim journalGate = new(1);
    public event Action? Changed;

    public static IReadOnlyList<WorkflowLogEntry> RunEntries(RunRecord run, int maxEntries = int.MaxValue)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(maxEntries);
        if (maxEntries == 0) return [];
        var terminal = TerminalEntry(run);
        (RunEvent Event, int Index)[] events;
        lock (run.Events) events = run.Events.Select((e, i) => (Event: e, Index: i)).Where(x => IsLog(x.Event))
            .TakeLast(maxEntries - (terminal is null ? 0 : 1)).ToArray();
        var result = events.Select(x => Entry(run, x.Event.Message, x.Event.Timestamp.LocalDateTime,
            x.Event.Kind == RunEventKind.NodeFailed ? LogLevel.Error : LogLevel.Info, x.Event.NodeId, run.Id + "/event/" + x.Index)).ToList();
        if (terminal is not null) result.Add(terminal);
        return result;
    }
    private static bool IsLog(RunEvent ev) => ev.Kind is RunEventKind.Log or RunEventKind.NodeFailed;
    private static WorkflowLogEntry? TerminalEntry(RunRecord run)
    {
        if (run.FinishedAt is not { } finished || run.Status is not (RunStatus.Failed or RunStatus.Cancelled or RunStatus.Interrupted or RunStatus.Skipped)) return null;
        lock (run.Events) if (run.Status == RunStatus.Failed && run.Events.Any(e => e.Kind == RunEventKind.NodeFailed && e.NodeId == run.ErrorNodeId)) return null;
        return Entry(run, run.Error ?? "运行状态:" + run.Status, finished.LocalDateTime,
            run.Status == RunStatus.Failed ? LogLevel.Error : LogLevel.Warning, run.ErrorNodeId, run.Id + "/finished");
    }
    private static WorkflowLogEntry Entry(RunRecord run, string message, DateTime time, LogLevel level, string? node, string id) => new()
    {
        Id = id, Category = "工作流运行", Time = time, Level = level, LogText = message, WorkflowId = run.WorkflowId,
        WorkflowName = run.WorkflowName, RunId = run.Id, NodeId = node, NodeName = node is null ? "" : run.NodeNames.GetValueOrDefault(node, node), Trigger = run.Trigger
    };
    public void Observe(RunRecord run)
    {
        lock (gate)
        {
            var previous = observed.GetValueOrDefault(run.Id);
            int eventCount; RunEvent[] events;
            lock (run.Events) { eventCount = run.Events.Count; events = run.Events.Skip(previous.Count).ToArray(); }
            if (!live.TryGetValue(run.Id, out var entries)) live[run.Id] = entries = [];
            foreach (var (ev, index) in events.Select((ev, index) => (ev, index)))
            {
                if (!IsLog(ev)) continue;
                var entry = Entry(run, ev.Message, ev.Timestamp.LocalDateTime, ev.Kind == RunEventKind.NodeFailed ? LogLevel.Error : LogLevel.Info, ev.NodeId, run.Id + "/event/" + (previous.Count + index));
                entries.Add(entry); Publish(entry);
            }
            if (run.FinishedAt is not null && !previous.Finished)
            {
                if (TerminalEntry(run) is { } terminal) Publish(terminal);
                // The coordinator persists the finished record before this notification.
                live.Remove(run.Id);
            }
            observed[run.Id] = (eventCount, run.FinishedAt is not null);
            if (observed.Count > 2048) foreach (var id in observed.Where(x => x.Value.Finished).Take(512).Select(x => x.Key).ToArray()) { observed.Remove(id); live.Remove(id); }
        }
        Changed?.Invoke();
    }
    private void Publish(WorkflowLogEntry entry)
    { entry.LogText = redactor.Redact(entry.LogText); XFEConsole.Log.AddLog(entry); }

    public async Task<IReadOnlyList<WorkflowLogEntry>> DiagnosticsAsync(WorkflowDefinition workflow, IReadOnlyList<WorkflowDiagnostic> diagnostics, CancellationToken ct = default)
    {
        var entries = diagnostics.Select(d => new WorkflowLogEntry
        {
            Category = "诊断", WorkflowId = workflow.Id, WorkflowName = workflow.Name, NodeId = d.NodeId,
            NodeName = d.NodeId ?? d.File ?? "编译与校验", Level = d.IsError ? LogLevel.Error : LogLevel.Warning,
            LogText = redactor.Redact(d.Message), Diagnostic = d with { Message = redactor.Redact(d.Message) }
        }).ToArray();
        await journalGate.WaitAsync(ct);
        try
        {
            var signature = JsonSerializer.Serialize(diagnostics, WorkflowJson.Options);
            if (diagnosticVersions.GetValueOrDefault(workflow.Id) == signature) return entries;
            diagnosticVersions[workflow.Id] = signature;
            if (entries.Length == 0) return entries;
            var path = WorkspacePaths.Inside(paths.Logs, workflow.Id + ".diagnostics.json");
            var previous = await AtomicJson.ReadAsync<List<WorkflowLogEntry>>(path, ct) ?? [];
            await AtomicJson.WriteAsync(path, previous.Concat(entries).TakeLast(5000).ToList(), ct);
            lock (gate) foreach (var entry in entries) Publish(entry);
        }
        finally { journalGate.Release(); }
        Changed?.Invoke(); return entries;
    }
    public async Task<List<WorkflowLogEntry>> HistoryAsync(CancellationToken ct = default)
    {
        var entries = (await runs.HistoryAsync(ct)).SelectMany(run => RunEntries(run)).ToList();
        lock (gate) entries.AddRange(live.Values.SelectMany(x => x));
        foreach (var file in Directory.EnumerateFiles(paths.Logs, "*.diagnostics.json"))
        {
            try { entries.AddRange(await AtomicJson.ReadAsync<List<WorkflowLogEntry>>(file, ct) ?? []); }
            catch (JsonException) { }
        }
        // Workflow entries in the XFE console are already represented by their
        // durable run record or diagnostic journal. Do not duplicate them.
        XFELogEntry[] application;
        try { lock (gate) application = XFEConsole.Log.Logs.ToArray(); }
        catch (FileNotFoundException) { application = []; } // File logs are created on the first write.
        entries.AddRange(application.Select((x, i) => (Entry: x, Index: i)).Where(x => x.Entry is not WorkflowLogEntry)
            .Select(x => new WorkflowLogEntry { Id = "application/" + x.Entry.Time.Ticks + "/" + x.Index, Time = x.Entry.Time, Level = x.Entry.Level, LogText = redactor.Redact(x.Entry.LogText) }));
        foreach (var entry in entries) entry.LogText = redactor.Redact(entry.LogText);
        return entries.DistinctBy(x => x.Id).OrderByDescending(x => x.Time).ToList();
    }
}