返回提交历史
Modified
XFEWorkflow/XFEWorkflow.Abstractions/WorkflowModels.cs
+7
-1
Modified
XFEWorkflow/XFEWorkflow.Infrastructure/Execution/RunCoordinator.cs
+9
-3
Added
XFEWorkflow/XFEWorkflow.Infrastructure/Execution/TriggerInputs.cs
+47
-0
Modified
XFEWorkflow/XFEWorkflow.Infrastructure/Execution/WorkflowScheduler.cs
+42
-14
Added
XFEWorkflow/XFEWorkflow.Infrastructure/Storage/WorkflowLogService.cs
+113
-0
Modified
XFEWorkflow/XFEWorkflow.Infrastructure/Storage/WorkspaceStorage.cs
+7
-3
Modified
XFEWorkflow/XFEWorkflow.Infrastructure/XFEWorkflow.Infrastructure.csproj
+2
-0
Added
XFEWorkflow/XFEWorkflow.Test/WorkbenchTests.cs
+129
-0
XFEstudio/XFEWorkflow
支持触发器输入映射、工作流完成联动与 XFE 运行日志持久化
b635a85
代码差异
8 个文件
+356
-21
@@ -8,7 +8,7 @@ public enum WorkflowKind { Graph, CSharp }
8
8
public enum PortKind { Execution, Data }
9
9
public enum WorkflowValueType { Any, String, Number, Boolean, Object, Array, Resource }
10
10
public enum RunStatus { Pending, Running, Succeeded, Failed, Cancelled, Interrupted, Skipped }
11
public enum TriggerKind { Manual, Interval, Daily, FileChanged, Plugin }
11
public enum TriggerKind { Manual, Interval, Daily, FileChanged, Plugin, WorkflowCompleted }
12
12
public enum RunEventKind { Started, NodeStarted, NodeSucceeded, NodeFailed, Log, Finished }
13
13
14
14
public sealed class WorkflowDefinition
@@ -84,6 +84,9 @@ public sealed class TriggerDefinition
84
84
public string Filter { get; set; } = "*";
85
85
public string CapabilityId { get; set; } = "";
86
86
public JsonObject Parameters { get; set; } = [];
87
public JsonObject InputBindings { get; set; } = [];
88
public string SourceWorkflowId { get; set; } = "";
89
public RunStatus? CompletionStatus { get; set; } = RunStatus.Succeeded;
87
90
}
88
91
89
92
public sealed class RunRecord
@@ -97,6 +100,9 @@ public sealed class RunRecord
97
100
public DateTimeOffset? FinishedAt { get; set; }
98
101
public string? Error { get; set; }
99
102
public string? ErrorNodeId { get; set; }
103
public JsonObject Inputs { get; set; } = [];
104
public List<string> WorkflowChain { get; set; } = [];
105
public Dictionary<string, string> NodeNames { get; set; } = [];
100
106
public JsonObject Outputs { get; set; } = [];
101
107
public List<RunEvent> Events { get; set; } = [];
102
108
[JsonIgnore] public string Summary => $"{StartedAt.ToLocalTime():MM-dd HH:mm:ss} {WorkflowName} · {Status}";
@@ -50,9 +50,15 @@ public sealed class RunCoordinator(WorkspacePaths paths, PluginManager plugins,
50
50
return result.OrderByDescending(x => x.StartedAt).ToList();
51
51
}
52
52
private Task SaveAsync(RunRecord record, CancellationToken ct = default) => AtomicJson.WriteAsync(WorkspacePaths.Inside(paths.Runs, record.Id + ".json"), record, ct);
53
public async Task<RunRecord> RunAsync(WorkflowDefinition workflow, string trigger = "手动", JsonObject? inputs = null, CancellationToken ct = default)
53
public async Task<RunRecord> RunAsync(WorkflowDefinition workflow, string trigger = "手动", JsonObject? inputs = null, CancellationToken ct = default, IReadOnlyList<string>? workflowChain = null)
54
54
{
55
var record = new RunRecord { WorkflowId = workflow.Id, WorkflowName = workflow.Name, Trigger = trigger, Status = RunStatus.Pending };
55
var runInputs = WorkflowJson.Copy(workflow.InputDefaults);
56
foreach (var pair in inputs ?? []) runInputs[pair.Key] = pair.Value?.DeepClone();
57
var record = new RunRecord { WorkflowId = workflow.Id, WorkflowName = workflow.Name, Trigger = trigger, Status = RunStatus.Pending,
58
Inputs = Sanitize(runInputs), WorkflowChain = [.. workflowChain ?? [], workflow.Id] };
59
void CaptureNames(GraphDefinition graph)
60
{ foreach (var node in graph.Nodes) { record.NodeNames[node.Id] = node.Name; foreach (var child in node.Children.Values) CaptureNames(child); } }
61
CaptureNames(workflow.Graph);
56
62
var session = new RunSession(record.Id);
57
63
if (!active.TryAdd(workflow.Id, session))
58
64
{
@@ -105,7 +111,7 @@ public sealed class RunCoordinator(WorkspacePaths paths, PluginManager plugins,
105
111
session.Peer.Start();
106
112
var response = await session.Peer.RequestAsync<RunResponse>("run", new RunRequest
107
113
{
108
Workflow = workflow, RunId = record.Id, WorkspaceRoot = paths.Root, Plugins = required, Inputs = inputs ?? WorkflowJson.Copy(workflow.InputDefaults)
114
Workflow = workflow, RunId = record.Id, WorkspaceRoot = paths.Root, Plugins = required, Inputs = runInputs
109
115
}, linked.Token) ?? throw new IOException("Runner 未返回执行结果");
110
116
record.Status = response.Status; record.Outputs = Sanitize(response.Outputs); record.Error = response.Error is null ? null : redactor.Redact(response.Error); record.ErrorNodeId = response.NodeId;
111
117
}
@@ -0,0 +1,47 @@
1
using System.Text.Json.Nodes;
2
using XFEWorkflow.Abstractions;
3
4
namespace XFEWorkflow.Infrastructure.Execution;
5
6
public static class TriggerInputs
7
{
8
public static string Label(TriggerDefinition trigger) => trigger.Kind switch
9
{
10
TriggerKind.Interval => $"每 {trigger.IntervalSeconds} 秒",
11
TriggerKind.Daily => "每日 " + trigger.DailyTime,
12
TriggerKind.FileChanged => "文件变化 · " + trigger.Filter,
13
TriggerKind.Plugin => "插件事件 · " + trigger.CapabilityId,
14
TriggerKind.WorkflowCompleted => "工作流完成",
15
_ => "手动运行"
16
};
17
18
public static JsonObject Build(WorkflowDefinition workflow, TriggerDefinition trigger, JsonObject? data, DateTimeOffset firedAt)
19
{
20
var input = WorkflowJson.Copy(workflow.InputDefaults);
21
var payload = WorkflowJson.Copy(data);
22
foreach (var pair in payload) input[pair.Key] = pair.Value?.DeepClone();
23
foreach (var pair in trigger.InputBindings) input[pair.Key] = Resolve(pair.Value, payload);
24
input["trigger"] = new JsonObject
25
{
26
["id"] = trigger.Id, ["kind"] = trigger.Kind.ToString(), ["name"] = Label(trigger),
27
["firedAt"] = firedAt, ["data"] = payload
28
};
29
return input;
30
}
31
32
private static JsonNode? Resolve(JsonNode? value, JsonObject data)
33
{
34
if (value is JsonObject obj)
35
{
36
if (obj.Count == 1 && obj["$event"] is { } reference)
37
return WorkflowJson.ReadPath(data, reference.Text())?.DeepClone();
38
var result = new JsonObject();
39
foreach (var pair in obj) result[pair.Key] = Resolve(pair.Value, data);
40
return result;
41
}
42
if (value is JsonArray array) return new JsonArray(array.Select(x => Resolve(x, data)).ToArray());
43
return value?.DeepClone();
44
}
45
}
46
47
public sealed record TriggerScheduleStatus(string TriggerId, string Label, bool Enabled, DateTimeOffset? NextRun, string? Error);
@@ -1,5 +1,6 @@
1
1
using System.Collections.Concurrent;
2
2
using System.Text.Json.Nodes;
3
using System.Text.Json;
3
4
using XFEWorkflow.Abstractions;
4
5
using XFEWorkflow.Infrastructure.Storage;
5
6
@@ -13,15 +14,25 @@ public sealed class WorkflowScheduler(WorkflowRepository repository, RunCoordina
13
14
private readonly List<Task> pluginWorkers = [];
14
15
private readonly ConcurrentDictionary<string, DateTimeOffset> next = [];
15
16
private readonly ConcurrentDictionary<string, long> fileDebounce = [];
17
private readonly ConcurrentDictionary<string, string> signatures = [];
18
private readonly ConcurrentDictionary<string, string> failures = [];
19
private readonly ConcurrentDictionary<string, DateTimeOffset> completed = [];
16
20
private readonly SemaphoreSlim refreshGate = new(1);
17
21
private List<WorkflowDefinition> workflows = [];
18
22
private Task? loop;
19
23
private volatile bool suspended;
20
24
public bool IsSuspended => suspended;
21
public void Suspend() => suspended = true;
22
public async Task ResumeAsync() { await RefreshAsync(); suspended = false; }
25
public event Action? Changed;
26
public void Suspend() { suspended = true; Changed?.Invoke(); }
27
public async Task ResumeAsync() { await RefreshAsync(); suspended = false; Changed?.Invoke(); }
23
28
public event Action<string>? Error;
24
public async Task StartAsync() { await RefreshAsync(); loop = LoopAsync(); }
29
public async Task StartAsync() { await RefreshAsync(); coordinator.RunChanged += WorkflowFinished; loop = LoopAsync(); }
30
public IReadOnlyList<TriggerScheduleStatus> StatusFor(WorkflowDefinition workflow) => workflow.Triggers.Select(trigger =>
31
{
32
var key = workflow.Id + "/" + trigger.Id;
33
var due = next.GetValueOrDefault(key, DateTimeOffset.MaxValue);
34
return new TriggerScheduleStatus(trigger.Id, TriggerInputs.Label(trigger), trigger.Enabled, due == DateTimeOffset.MaxValue ? null : due, failures.GetValueOrDefault(key));
35
}).ToArray();
25
36
public async Task RefreshAsync()
26
37
{
27
38
await refreshGate.WaitAsync(lifetime.Token);
@@ -31,11 +42,15 @@ public sealed class WorkflowScheduler(WorkflowRepository repository, RunCoordina
31
42
watchers.Clear();
32
43
await StopPluginWorkersAsync();
33
44
workflows = await repository.ListAsync(lifetime.Token);
45
failures.Clear();
46
var keys = workflows.SelectMany(w => w.Triggers.Where(t => t.Enabled).Select(t => w.Id + "/" + t.Id)).ToHashSet();
47
foreach (var key in next.Keys.Where(k => !keys.Contains(k))) { next.TryRemove(key, out _); signatures.TryRemove(key, out _); }
34
48
foreach (var workflow in workflows)
35
49
foreach (var trigger in workflow.Triggers.Where(x => x.Enabled))
36
50
{
37
51
var key = workflow.Id + "/" + trigger.Id;
38
next.TryAdd(key, NextDue(trigger, DateTimeOffset.Now));
52
var signature = JsonSerializer.Serialize(trigger, WorkflowJson.Options);
53
if (signatures.GetValueOrDefault(key) != signature) { next[key] = NextDue(trigger, DateTimeOffset.Now); signatures[key] = signature; }
39
54
if (trigger.Kind == TriggerKind.FileChanged)
40
55
{
41
56
try
@@ -46,13 +61,13 @@ public sealed class WorkflowScheduler(WorkflowRepository repository, RunCoordina
46
61
var now = Environment.TickCount64;
47
62
if (fileDebounce.TryGetValue(key, out var old) && now - old < 500) return;
48
63
fileDebounce[key] = now;
49
_ = FireAsync(workflow, "文件变化", new() { ["path"] = path });
64
_ = FireAsync(workflow, trigger, new() { ["path"] = path });
50
65
}
51
66
watcher.Changed += (_, e) => Fire(e.FullPath); watcher.Created += (_, e) => Fire(e.FullPath); watcher.Renamed += (_, e) => Fire(e.FullPath);
52
watcher.Error += (_, e) => Error?.Invoke("文件监听失败:" + e.GetException().Message);
67
watcher.Error += (_, e) => Fail(workflow, trigger, "文件监听失败:" + e.GetException().Message);
53
68
watcher.EnableRaisingEvents = true; watchers.Add(watcher);
54
69
}
55
catch (Exception ex) { Error?.Invoke(workflow.Name + ":" + ex.Message); }
70
catch (Exception ex) { Fail(workflow, trigger, ex.Message); }
56
71
}
57
72
else if (trigger.Kind == TriggerKind.Plugin)
58
73
{
@@ -62,7 +77,7 @@ public sealed class WorkflowScheduler(WorkflowRepository repository, RunCoordina
62
77
}
63
78
}
64
79
}
65
finally { refreshGate.Release(); }
80
finally { refreshGate.Release(); Changed?.Invoke(); }
66
81
}
67
82
public static DateTimeOffset NextDue(TriggerDefinition trigger, DateTimeOffset now)
68
83
{
@@ -86,23 +101,35 @@ public sealed class WorkflowScheduler(WorkflowRepository repository, RunCoordina
86
101
var key = workflow.Id + "/" + trigger.Id;
87
102
if (next.GetValueOrDefault(key, DateTimeOffset.MaxValue) > DateTimeOffset.Now) continue;
88
103
next[key] = NextDue(trigger, DateTimeOffset.Now);
89
_ = FireAsync(workflow, trigger.Kind == TriggerKind.Daily ? "每日定时" : "间隔定时", null);
104
_ = FireAsync(workflow, trigger, new() { ["scheduledAt"] = DateTimeOffset.Now });
105
Changed?.Invoke();
90
106
}
91
107
}
92
108
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
93
109
}
94
110
private async Task RunPluginTriggerAsync(WorkflowDefinition workflow, TriggerDefinition trigger, CancellationToken ct)
95
111
{
96
try { await coordinator.StartTriggerAsync(workflow, trigger, input => new ValueTask(FireAsync(workflow, "插件触发", input)), ct); }
112
try { await coordinator.StartTriggerAsync(workflow, trigger, input => new ValueTask(FireAsync(workflow, trigger, input)), ct); }
97
113
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
98
catch (Exception ex) { Error?.Invoke("插件触发器已停止:" + ex.Message); }
114
catch (Exception ex) { Fail(workflow, trigger, "插件触发器已停止:" + ex.Message); }
115
}
116
private void Fail(WorkflowDefinition workflow, TriggerDefinition trigger, string message)
117
{ failures[workflow.Id + "/" + trigger.Id] = message; Error?.Invoke(workflow.Name + " · " + TriggerInputs.Label(trigger) + ":" + message); Changed?.Invoke(); }
118
private void WorkflowFinished(RunRecord record)
119
{
120
if (record.FinishedAt is null || record.Status is RunStatus.Skipped or RunStatus.Pending or RunStatus.Running || !completed.TryAdd(record.Id, DateTimeOffset.UtcNow)) return;
121
if (completed.Count > 2048) foreach (var old in completed.OrderBy(x => x.Value).Take(512)) completed.TryRemove(old.Key, out _);
122
foreach (var workflow in workflows.ToArray())
123
foreach (var trigger in workflow.Triggers.Where(t => t.Enabled && t.Kind == TriggerKind.WorkflowCompleted && t.SourceWorkflowId == record.WorkflowId && (t.CompletionStatus is null || t.CompletionStatus == record.Status)))
124
_ = FireAsync(workflow, trigger, new() { ["workflowId"] = record.WorkflowId, ["runId"] = record.Id, ["status"] = record.Status.ToString(), ["outputs"] = record.Outputs.DeepClone(), ["error"] = record.Error }, record.WorkflowChain.Count == 0 ? [record.WorkflowId] : record.WorkflowChain);
99
125
}
100
private async Task FireAsync(WorkflowDefinition workflow, string reason, JsonObject? input)
126
private async Task FireAsync(WorkflowDefinition workflow, TriggerDefinition trigger, JsonObject? input, IReadOnlyList<string>? chain = null)
101
127
{
102
128
if (suspended) return;
103
try { await coordinator.RunAsync(WorkflowJson.Clone(workflow), reason, input, lifetime.Token); }
129
if (chain?.Contains(workflow.Id) == true) { Fail(workflow, trigger, "检测到工作流联动环路,已阻止重复触发"); return; }
130
try { await coordinator.RunAsync(WorkflowJson.Clone(workflow), TriggerInputs.Label(trigger), TriggerInputs.Build(workflow, trigger, input, DateTimeOffset.UtcNow), lifetime.Token, chain); }
104
131
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
105
catch (Exception ex) { Error?.Invoke(ex.Message); }
132
catch (Exception ex) { Fail(workflow, trigger, ex.Message); }
106
133
}
107
134
private async Task StopPluginWorkersAsync()
108
135
{
@@ -113,6 +140,7 @@ public sealed class WorkflowScheduler(WorkflowRepository repository, RunCoordina
113
140
}
114
141
public async ValueTask DisposeAsync()
115
142
{
143
coordinator.RunChanged -= WorkflowFinished;
116
144
await lifetime.CancelAsync();
117
145
foreach (var watcher in watchers) watcher.Dispose();
118
146
await StopPluginWorkersAsync();
@@ -0,0 +1,113 @@
1
using System.Text.Json;
2
using System.Text.Json.Serialization;
3
using XFEExtension.NetCore.XFEConsole;
4
using XFEWorkflow.Abstractions;
5
using XFEWorkflow.Core.Runtime;
6
using XFEWorkflow.Infrastructure.Execution;
7
8
namespace XFEWorkflow.Infrastructure.Storage;
9
10
public sealed class WorkflowLogEntry : XFELogEntry
11
{
12
public string Id { get; set; } = Guid.NewGuid().ToString("N");
13
public string Category { get; set; } = "应用";
14
public string WorkflowId { get; set; } = "";
15
public string WorkflowName { get; set; } = "";
16
public string RunId { get; set; } = "";
17
public string? NodeId { get; set; }
18
public string NodeName { get; set; } = "";
19
public string Trigger { get; set; } = "";
20
public WorkflowDiagnostic? Diagnostic { get; set; }
21
[JsonIgnore] public string TimeText => Time.ToString("HH:mm:ss.fff");
22
[JsonIgnore] public string LevelLabel => Level.ToString().ToUpperInvariant();
23
[JsonIgnore] public string SourceLabel => WorkflowName.Length == 0 ? Category : WorkflowName + (NodeName.Length == 0 ? "" : " / " + NodeName);
24
[JsonIgnore] public string SearchText => $"{LogText} {SourceLabel} {RunId} {NodeId} {Trigger}";
25
}
26
27
/// <summary>Runner events retain their original run record; diagnostics use a separate journal.
28
/// Both are XFE log entries and share the same filtering, rendering and export pipeline.</summary>
29
public sealed class WorkflowLogService(WorkspacePaths paths, RunCoordinator runs, SecretRedactor redactor)
30
{
31
private readonly object gate = new();
32
private readonly Dictionary<string, (int Count, bool Finished)> observed = [];
33
private readonly Dictionary<string, WorkflowLogEntry[]> live = [];
34
private readonly Dictionary<string, string> diagnosticVersions = [];
35
private readonly SemaphoreSlim journalGate = new(1);
36
public event Action? Changed;
37
38
public static IReadOnlyList<WorkflowLogEntry> RunEntries(RunRecord run)
39
{
40
RunEvent[] events; lock (run.Events) events = run.Events.ToArray();
41
var result = events.Select((e, i) => Entry(run, e.Message, e.Timestamp.LocalDateTime,
42
e.Kind == RunEventKind.NodeFailed ? LogLevel.Error : LogLevel.Info, e.NodeId, run.Id + "/event/" + i)).ToList();
43
if (run.FinishedAt is { } finished)
44
result.Add(Entry(run, run.Error ?? "工作流执行完成:" + run.Status, finished.LocalDateTime,
45
run.Status == RunStatus.Failed ? LogLevel.Error : run.Status is RunStatus.Cancelled or RunStatus.Interrupted or RunStatus.Skipped ? LogLevel.Warning : LogLevel.Info,
46
run.ErrorNodeId, run.Id + "/finished"));
47
return result;
48
}
49
private static WorkflowLogEntry Entry(RunRecord run, string message, DateTime time, LogLevel level, string? node, string id) => new()
50
{
51
Id = id, Category = "工作流运行", Time = time, Level = level, LogText = message, WorkflowId = run.WorkflowId,
52
WorkflowName = run.WorkflowName, RunId = run.Id, NodeId = node, NodeName = node is null ? "" : run.NodeNames.GetValueOrDefault(node, node), Trigger = run.Trigger
53
};
54
public void Observe(RunRecord run)
55
{
56
lock (gate)
57
{
58
var previous = observed.GetValueOrDefault(run.Id);
59
var entries = RunEntries(run);
60
live[run.Id] = entries.ToArray();
61
var eventCount = entries.Count - (run.FinishedAt is null ? 0 : 1);
62
foreach (var entry in entries.Skip(previous.Count).Take(Math.Max(0, eventCount - previous.Count))) Publish(entry);
63
if (run.FinishedAt is not null && !previous.Finished) Publish(entries[^1]);
64
observed[run.Id] = (eventCount, run.FinishedAt is not null);
65
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); }
66
}
67
Changed?.Invoke();
68
}
69
private void Publish(WorkflowLogEntry entry)
70
{ entry.LogText = redactor.Redact(entry.LogText); XFEConsole.Log.AddLog(entry); }
71
72
public async Task<IReadOnlyList<WorkflowLogEntry>> DiagnosticsAsync(WorkflowDefinition workflow, IReadOnlyList<WorkflowDiagnostic> diagnostics, CancellationToken ct = default)
73
{
74
var entries = diagnostics.Select(d => new WorkflowLogEntry
75
{
76
Category = "诊断", WorkflowId = workflow.Id, WorkflowName = workflow.Name, NodeId = d.NodeId,
77
NodeName = d.NodeId ?? d.File ?? "编译与校验", Level = d.IsError ? LogLevel.Error : LogLevel.Warning,
78
LogText = redactor.Redact(d.Message), Diagnostic = d with { Message = redactor.Redact(d.Message) }
79
}).ToArray();
80
await journalGate.WaitAsync(ct);
81
try
82
{
83
var signature = JsonSerializer.Serialize(diagnostics, WorkflowJson.Options);
84
if (diagnosticVersions.GetValueOrDefault(workflow.Id) == signature) return entries;
85
diagnosticVersions[workflow.Id] = signature;
86
if (entries.Length == 0) return entries;
87
var path = WorkspacePaths.Inside(paths.Logs, workflow.Id + ".diagnostics.json");
88
var previous = await AtomicJson.ReadAsync<List<WorkflowLogEntry>>(path, ct) ?? [];
89
await AtomicJson.WriteAsync(path, previous.Concat(entries).TakeLast(5000).ToList(), ct);
90
lock (gate) foreach (var entry in entries) Publish(entry);
91
}
92
finally { journalGate.Release(); }
93
Changed?.Invoke(); return entries;
94
}
95
public async Task<List<WorkflowLogEntry>> HistoryAsync(CancellationToken ct = default)
96
{
97
var entries = (await runs.HistoryAsync(ct)).SelectMany(RunEntries).ToList();
98
lock (gate) entries.AddRange(live.Values.SelectMany(x => x));
99
foreach (var file in Directory.EnumerateFiles(paths.Logs, "*.diagnostics.json"))
100
{
101
try { entries.AddRange(await AtomicJson.ReadAsync<List<WorkflowLogEntry>>(file, ct) ?? []); }
102
catch (JsonException) { }
103
}
104
// Workflow entries in the XFE console are already represented by their
105
// durable run record or diagnostic journal. Do not duplicate them.
106
XFELogEntry[] application;
107
lock (gate) application = XFEConsole.Log.Logs.ToArray();
108
entries.AddRange(application.Select((x, i) => (Entry: x, Index: i)).Where(x => x.Entry is not WorkflowLogEntry)
109
.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) }));
110
foreach (var entry in entries) entry.LogText = redactor.Redact(entry.LogText);
111
return entries.DistinctBy(x => x.Id).OrderByDescending(x => x.Time).ToList();
112
}
113
}
@@ -13,10 +13,11 @@ public sealed class WorkspacePaths
13
13
public string Runs => Path.Combine(Root, "Runs");
14
14
public string Temp => Path.Combine(Root, "Temp");
15
15
public string BrowserProfiles => Path.Combine(Root, "BrowserProfiles");
16
public string Logs => Path.Combine(Root, "Logs");
16
17
public WorkspacePaths(string root)
17
18
{
18
19
Root = Path.GetFullPath(root);
19
foreach (var directory in new[] { Root, Workflows, Plugins, Runs, Temp, BrowserProfiles }) Directory.CreateDirectory(directory);
20
foreach (var directory in new[] { Root, Workflows, Plugins, Runs, Temp, BrowserProfiles, Logs }) Directory.CreateDirectory(directory);
20
21
}
21
22
public static string Inside(string root, string relative)
22
23
{
@@ -131,7 +132,6 @@ public sealed class WorkflowRepository(WorkspacePaths paths)
131
132
public partial class ClientStateProfile : XFEProfile
132
133
{
133
134
[ProfileProperty] private List<InstalledPlugin> _plugins = [];
134
[ProfileProperty] private string _spaceAddress = "http://localhost:3300/space/v1/";
135
135
[ProfileProperty] private string _deviceId = Guid.NewGuid().ToString("N");
136
136
[ProfileProperty] private string _desktopBackend = "standard";
137
137
[ProfileProperty] private Dictionary<string, string> _credentials = [];
@@ -146,6 +146,8 @@ public partial class ClientStateProfile : XFEProfile
146
146
147
147
public sealed class ClientState : IAsyncDisposable
148
148
{
149
public const string LocalServerAddress = "http://localhost:3300/space/v1/";
150
private string serverAddress = LocalServerAddress;
149
151
public ProfileStore<ClientStateProfile> Store { get; }
150
152
public ClientState(WorkspacePaths paths)
151
153
{
@@ -153,7 +155,9 @@ public sealed class ClientState : IAsyncDisposable
153
155
Store.Load();
154
156
}
155
157
public List<InstalledPlugin> Plugins => Store.Read(p => WorkflowJson.Clone(p.InstancePlugins));
156
public string SpaceAddress { get => Store.Read(p => p.InstanceSpaceAddress); set => Store.Update(p => p.InstanceSpaceAddress = value); }
158
// The desktop product owns its server endpoint. Persisted legacy addresses
159
// are ignored; only the internal UI fixture can substitute a loopback server.
160
public string SpaceAddress { get => serverAddress; internal set => serverAddress = Space.SpaceClient.ValidateAddress(value); }
157
161
public string DeviceId => Store.Read(p => p.InstanceDeviceId);
158
162
public string DesktopBackend { get => Store.Read(p => p.InstanceDesktopBackend); set => Store.Update(p => p.InstanceDesktopBackend = value); }
159
163
public double PaneSize(string key, double fallback) => Store.Read(p => p.InstancePaneSizes.TryGetValue(key, out var value) && double.IsFinite(value) && value > 0 ? value : fallback);
@@ -7,6 +7,7 @@
7
7
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="5.6.0" />
8
8
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.12" />
9
9
<PackageReference Include="XFEExtension.NetCore.ServerInteractive" Version="4.0.1" />
10
<PackageReference Include="XFEExtension.NetCore.XFEConsole" Version="2.6.0" />
10
11
<PackageDownload Include="Microsoft.NETCore.App.Ref" Version="[10.0.12]" />
11
12
</ItemGroup>
12
13
<ItemGroup>
@@ -14,4 +15,5 @@
14
15
Link="CompilerReferences\%(Filename)%(Extension)"
15
16
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
16
17
</ItemGroup>
18
<ItemGroup><InternalsVisibleTo Include="XFEWorkflow" /><InternalsVisibleTo Include="XFEWorkflow.Test" /></ItemGroup>
17
19
</Project>
@@ -0,0 +1,129 @@
1
using System.Text.Json.Nodes;
2
using XFEExtension.NetCore.XFEConsole;
3
using XFEExtension.NetCore.XUnit.Assertions;
4
using XFEExtension.NetCore.XUnit.Attributes;
5
using XFEWorkflow.Abstractions;
6
using XFEWorkflow.Core.Runtime;
7
using XFEWorkflow.Infrastructure.Execution;
8
using XFEWorkflow.Infrastructure.Plugins;
9
using XFEWorkflow.Infrastructure.Storage;
10
11
namespace XFEWorkflow.Test;
12
13
[TestFixture]
14
public class WorkbenchTests
15
{
16
private static RunCoordinator Coordinator(TestWorkspace workspace, SecretRedactor? redactor = null) => new(workspace.Paths,
17
new PluginManager(workspace.Paths, workspace.State), new CredentialVault(workspace.State), redactor ?? new(), TestSupport.FindOutput("XFEWorkflow.Runner", "XFEWorkflow.Runner.exe"));
18
19
[Test]
20
public void TriggerBindingsPreserveDefaultsAndCopyNestedEventValues()
21
{
22
var workflow = TestSupport.Graph(); workflow.InputDefaults = new() { ["default"] = "keep", ["value"] = "old" };
23
var trigger = new TriggerDefinition { Kind = TriggerKind.WorkflowCompleted, InputBindings = new()
24
{
25
["value"] = new JsonObject { ["$event"] = "outputs.result" },
26
["nested"] = new JsonArray(new JsonObject { ["$event"] = "outputs.result" }, "constant"),
27
["missing"] = new JsonObject { ["$event"] = "unknown" }
28
} };
29
var payload = new JsonObject { ["outputs"] = new JsonObject { ["result"] = "联动输出" }, ["eventValue"] = 42 };
30
var input = TriggerInputs.Build(workflow, trigger, payload, DateTimeOffset.UtcNow);
31
Assert.Equal("keep", input.String("default")); Assert.Equal("联动输出", input.String("value")); Assert.Equal(42, input.Integer("eventValue"));
32
Assert.Equal("联动输出", input["nested"]![0]!.GetValue<string>()); Assert.True(input["missing"] is null);
33
Assert.Equal(trigger.Id, input["trigger"]!["id"]!.GetValue<string>());
34
payload["outputs"]!["result"] = "changed";
35
Assert.Equal("联动输出", input["trigger"]!["data"]!["outputs"]!["result"]!.GetValue<string>());
36
Assert.Equal("old", workflow.InputDefaults.String("value"));
37
}
38
39
[Test]
40
public async Task RunnerMergesExplicitInputsWithWorkflowDefaultsAndPersistsInputs()
41
{
42
await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
43
var workflow = TestSupport.Graph(); workflow.InputDefaults = new() { ["default"] = "keep", ["value"] = "old" };
44
workflow.Graph.Nodes[^1].Parameters["result"] = new JsonObject { ["$input"] = "default" };
45
var run = await coordinator.RunAsync(workflow, inputs: new() { ["value"] = "new" });
46
Assert.Equal(RunStatus.Succeeded, run.Status, run.Error); Assert.Equal("keep", run.Outputs.String("result"));
47
Assert.Equal("new", run.Inputs.String("value")); Assert.Equal(workflow.Id, run.WorkflowChain.Single());
48
var saved = (await coordinator.HistoryAsync()).Single(); Assert.Equal("keep", saved.Inputs.String("default")); Assert.Equal(workflow.Graph.Nodes[0].Name, saved.NodeNames[workflow.Graph.Nodes[0].Id]);
49
}
50
51
[Test]
52
public async Task CompletionTriggerMapsOutputsAndBlocksCycles()
53
{
54
await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
55
var source = TestSupport.Graph(); source.Name = "上游"; source.Graph.Nodes[^1].Parameters["result"] = "upstream";
56
var target = TestSupport.Graph(); target.Name = "下游"; target.Graph.Nodes[^1].Parameters["result"] = new JsonObject { ["$input"] = "message" };
57
target.Triggers.Add(new() { Kind = TriggerKind.WorkflowCompleted, Enabled = true, SourceWorkflowId = source.Id,
58
InputBindings = new() { ["message"] = new JsonObject { ["$event"] = "outputs.result" } } });
59
source.Triggers.Add(new() { Kind = TriggerKind.WorkflowCompleted, Enabled = true, SourceWorkflowId = target.Id });
60
var repository = new WorkflowRepository(workspace.Paths); await repository.SaveAsync(source); await repository.SaveAsync(target);
61
await using var scheduler = new WorkflowScheduler(repository, coordinator);
62
var finished = new TaskCompletionSource<RunRecord>(TaskCreationOptions.RunContinuationsAsynchronously);
63
var cycle = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
64
scheduler.Error += message => { if (message.Contains("环路")) cycle.TrySetResult(message); };
65
coordinator.RunChanged += run => { if (run.WorkflowId == target.Id && run.Status == RunStatus.Succeeded) finished.TrySetResult(run); };
66
await scheduler.StartAsync(); var first = await coordinator.RunAsync(source); Assert.Equal(RunStatus.Succeeded, first.Status, first.Error);
67
var linked = await finished.Task.WaitAsync(TimeSpan.FromSeconds(15)); await cycle.Task.WaitAsync(TimeSpan.FromSeconds(5));
68
Assert.Equal("upstream", linked.Outputs.String("result")); Assert.Equal(first.Id, linked.Inputs["trigger"]!["data"]!["runId"]!.GetValue<string>());
69
Assert.True(linked.WorkflowChain.SequenceEqual(new[] { source.Id, target.Id }));
70
Assert.Equal(2, (await coordinator.HistoryAsync()).Count);
71
Assert.True(scheduler.StatusFor(source).Single().Error!.Contains("环路"));
72
}
73
74
[Test]
75
public async Task CompletionStatusFilterDoesNotRunOnFailure()
76
{
77
await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
78
var source = TestSupport.Graph(new NodeDefinition { CapabilityId = "core.csharp", Code = "throw new InvalidOperationException(\"expected\");" });
79
var target = TestSupport.Graph(); target.Triggers.Add(new() { Kind = TriggerKind.WorkflowCompleted, Enabled = true, SourceWorkflowId = source.Id, CompletionStatus = RunStatus.Succeeded });
80
var repository = new WorkflowRepository(workspace.Paths); await repository.SaveAsync(source); await repository.SaveAsync(target);
81
await using var scheduler = new WorkflowScheduler(repository, coordinator); await scheduler.StartAsync();
82
var record = await coordinator.RunAsync(source); Assert.Equal(RunStatus.Failed, record.Status); Assert.Equal(1, (await coordinator.HistoryAsync()).Count);
83
}
84
85
[Test]
86
public async Task EditingIntervalResetsNextExecutionButUnchangedRefreshPreservesIt()
87
{
88
await using var workspace = new TestWorkspace(); await using var coordinator = Coordinator(workspace);
89
var workflow = TestSupport.Graph(); workflow.Triggers.Add(new() { Kind = TriggerKind.Interval, Enabled = true, IntervalSeconds = 600 });
90
var repository = new WorkflowRepository(workspace.Paths); await repository.SaveAsync(workflow);
91
await using var scheduler = new WorkflowScheduler(repository, coordinator); await scheduler.StartAsync();
92
var original = scheduler.StatusFor(workflow).Single().NextRun; await scheduler.RefreshAsync(); Assert.Equal(original, scheduler.StatusFor(workflow).Single().NextRun);
93
workflow.Triggers[0].IntervalSeconds = 30; await repository.SaveAsync(workflow); await scheduler.RefreshAsync();
94
Assert.True(scheduler.StatusFor(workflow).Single().NextRun < original!.Value.AddMinutes(-8));
95
workflow.Triggers[0].Enabled = false; await repository.SaveAsync(workflow); await scheduler.RefreshAsync(); Assert.True(scheduler.StatusFor(workflow).Single().NextRun is null);
96
}
97
98
[Test]
99
public async Task WorkflowLogsAreXfeEntriesVisibleLiveDurableAndRedacted()
100
{
101
await using var workspace = new TestWorkspace(); var redactor = new SecretRedactor(); redactor.Add("test-secret-value");
102
await using var coordinator = Coordinator(workspace, redactor); var logs = new WorkflowLogService(workspace.Paths, coordinator, redactor); coordinator.RunChanged += logs.Observe;
103
var step = new NodeDefinition { CapabilityId = "core.log", Name = "日志节点", Parameters = new() { ["message"] = "日志 test-secret-value" } };
104
var workflow = TestSupport.Graph(step, new() { CapabilityId = "core.delay", Parameters = new() { ["milliseconds"] = 800 } });
105
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
106
coordinator.RunChanged += run => { if (run.Events.Any(e => e.Message.Contains("日志 "))) started.TrySetResult(); };
107
var running = coordinator.RunAsync(workflow); await started.Task.WaitAsync(TimeSpan.FromSeconds(15));
108
var live = (await logs.HistoryAsync()).Where(e => e.WorkflowId == workflow.Id).ToArray();
109
Assert.True(live.Any(e => e.NodeName == "日志节点" && e.LogText.Contains("日志 "))); Assert.True(live.All(e => e is XFELogEntry && !e.LogText.Contains("test-secret-value")));
110
var result = await running; Assert.Equal(RunStatus.Succeeded, result.Status, result.Error);
111
await logs.DiagnosticsAsync(workflow, [new("诊断 test-secret-value", step.Id)]);
112
var reopened = new WorkflowLogService(workspace.Paths, coordinator, redactor); var history = (await reopened.HistoryAsync()).Where(e => e.WorkflowId == workflow.Id).ToList();
113
Assert.True(history.Any(e => e.RunId == result.Id && e.NodeId == step.Id)); Assert.True(history.Any(e => e.Category == "诊断" && e.Diagnostic is not null));
114
Assert.True(history.All(e => !e.LogText.Contains("test-secret-value"))); Assert.Equal(history.Count, history.Select(e => e.Id).Distinct().Count());
115
coordinator.RunChanged -= logs.Observe;
116
}
117
118
[Test]
119
public async Task LoginAndSpaceEndpointIsFixedAndCannotBeSetPublicly()
120
{
121
await using var workspace = new TestWorkspace();
122
Assert.Equal("http://localhost:3300/space/v1/", workspace.State.SpaceAddress);
123
Assert.False(typeof(ClientState).GetProperty(nameof(ClientState.SpaceAddress))!.SetMethod!.IsPublic);
124
Assert.False(typeof(ClientState).GetProperties().Any(x => x.Name.Contains("InstanceSpaceAddress", StringComparison.Ordinal)));
125
await workspace.State.SaveAsync();
126
var text = await File.ReadAllTextAsync(Path.Combine(workspace.Paths.Root, "ClientState.xpf"));
127
Assert.False(text.Contains("spaceAddress", StringComparison.OrdinalIgnoreCase));
128
}
129
}