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

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEWorkflow

完善触发器凭据引用、参数校验和监听失败反馈

99d2322
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

8 个文件 +125 -22
Modified XFEWorkflow/XFEWorkflow.Core/Runtime/CapabilityRegistry.cs +25 -7
@@ -22,13 +22,7 @@ public sealed class CapabilityRegistry : IPluginRegistry, ICapabilityInvoker
22 22 public async ValueTask<JsonObject> InvokeAsync(string capabilityId, JsonObject inputs, IWorkflowContext context, CancellationToken cancellationToken)
23 23 {
24 24 if (!actions.TryGetValue(capabilityId, out var entry)) throw new InvalidOperationException($"找不到能力:{capabilityId}");
25 foreach (var port in entry.Descriptor.Inputs)
26 {
27 if (!inputs.ContainsKey(port.Name) && port.DefaultValue is not null) inputs[port.Name] = port.DefaultValue.DeepClone();
28 if (port.Required && inputs[port.Name] is null) throw new ArgumentException($"缺少参数:{port.Label}");
29 if (inputs[port.Name] is { } value && !GraphValidator.Matches(value, port.Type))
30 throw new ArgumentException($"参数 {port.Label} 的类型应为 {port.Type}");
31 }
25 ValidateInputs(entry.Descriptor, inputs);
32 26 var action = entry.Factory();
33 27 try { return await action.ExecuteAsync(inputs, context, cancellationToken); }
34 28 finally
@@ -37,6 +31,30 @@ public sealed class CapabilityRegistry : IPluginRegistry, ICapabilityInvoker
37 31 else if (action is IDisposable disposable) disposable.Dispose();
38 32 }
39 33 }
34 public async Task RunTriggerAsync(string capabilityId, JsonObject parameters, WorkflowExecutionContext context,
35 Func<JsonObject, ValueTask> emit, CancellationToken cancellationToken)
36 {
37 if (!triggers.TryGetValue(capabilityId, out var entry)) throw new InvalidOperationException($"找不到触发器:{capabilityId}");
38 var inputs = (await WorkflowEngine.ResolveAsync(parameters, context, cancellationToken))?.AsObject() ?? [];
39 ValidateInputs(entry.Descriptor, inputs);
40 var trigger = entry.Factory();
41 try { await trigger.RunAsync(inputs, context, emit, cancellationToken); }
42 finally
43 {
44 if (trigger is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync();
45 else if (trigger is IDisposable disposable) disposable.Dispose();
46 }
47 }
48 private static void ValidateInputs(CapabilityDescriptor descriptor, JsonObject inputs)
49 {
50 foreach (var port in descriptor.Inputs)
51 {
52 if (!inputs.ContainsKey(port.Name) && port.DefaultValue is not null) inputs[port.Name] = port.DefaultValue.DeepClone();
53 if (port.Required && inputs[port.Name] is null) throw new ArgumentException($"缺少参数:{port.Label}");
54 if (inputs[port.Name] is { } value && !GraphValidator.Matches(value, port.Type))
55 throw new ArgumentException($"参数 {port.Label} 的类型应为 {port.Type}");
56 }
57 }
40 58 public IWorkflowTrigger CreateTrigger(string capabilityId) =>
41 59 triggers.TryGetValue(capabilityId, out var entry) ? entry.Factory() : throw new InvalidOperationException($"找不到触发器:{capabilityId}");
42 60 }
Modified XFEWorkflow/XFEWorkflow.Infrastructure/Execution/RunCoordinator.cs +4 -3
@@ -186,11 +186,12 @@ public sealed class RunCoordinator(WorkspacePaths paths, PluginManager plugins,
186 186 using var registration = ct.Register(() => _ = session.StopAsync());
187 187 var triggerParameters = WorkflowJson.Copy(trigger.Parameters);
188 188 triggerParameters["_workflowId"] = workflow.Id; triggerParameters["_priority"] = workflow.Priority;
189 await session.Peer.RequestAsync<RunResponse>("run", new RunRequest
189 var response = await session.Peer.RequestAsync<RunResponse>("run", new RunRequest
190 190 {
191 191 Workflow = workflow, RunId = session.Id, WorkspaceRoot = paths.Root, Plugins = required,
192 TriggerCapability = trigger.CapabilityId, TriggerParameters = triggerParameters
193 }, ct);
192 TriggerCapability = trigger.CapabilityId, TriggerParameters = triggerParameters, Inputs = WorkflowJson.Copy(workflow.InputDefaults)
193 }, ct) ?? throw new IOException("触发器 Runner 未返回执行结果");
194 if (response.Status == RunStatus.Failed) throw new InvalidOperationException(redactor.Redact(response.Error ?? "插件触发器执行失败"));
194 195 }
195 196 finally
196 197 {
Modified XFEWorkflow/XFEWorkflow.Runner/Program.cs +1 -1
@@ -58,7 +58,7 @@ peer.RequestReceived = async (method, value, ct) =>
58 58 };
59 59 if (request.TriggerCapability is { } trigger)
60 60 {
61 await loader.Registry.CreateTrigger(trigger).RunAsync(request.TriggerParameters, CreateContext(request.Workflow, request.Inputs),
61 await loader.Registry.RunTriggerAsync(trigger, request.TriggerParameters, CreateContext(request.Workflow, request.Inputs),
62 62 data => peer.EventAsync("trigger.fire", data, token), token);
63 63 response.Status = RunStatus.Succeeded;
64 64 }
Modified XFEWorkflow/XFEWorkflow.Test/TriggerNodeTests.cs +32 -0
@@ -18,6 +18,32 @@ public class TriggerNodeTests
18 18 private static RunCoordinator Coordinator(TestWorkspace workspace) => new(workspace.Paths, new PluginManager(workspace.Paths, workspace.State),
19 19 new CredentialVault(workspace.State), new(), TestSupport.FindOutput("XFEWorkflow.Runner", "XFEWorkflow.Runner.exe"));
20 20
21 [Test]
22 public async Task PluginTriggerResolvesManagedCredentialsAndValidatesBeforeCreatingAndDisposesOnCancellation()
23 {
24 var registry = new CapabilityRegistry(); var created = 0; var disposed = 0; JsonObject? received = null;
25 registry.RegisterTrigger(new() { Id = "test.trigger", IsTrigger = true, Inputs =
26 [new() { Name = "key", Label = "凭据", Type = WorkflowValueType.String, Secret = true, Required = true },
27 new() { Name = "seconds", Label = "间隔", Type = WorkflowValueType.Number, DefaultValue = JsonValue.Create(10) }] },
28 () => { created++; return new DisposableTrigger(async (parameters, emit, ct) =>
29 { received = parameters; await emit(new() { ["ready"] = true }); ct.ThrowIfCancellationRequested(); }, () => disposed++); });
30 var source = new JsonObject { ["key"] = new JsonObject { ["$secret"] = "fixture" } };
31 var context = TestSupport.Context(registry); var emissions = 0;
32 ValueTask Emit(JsonObject _) { emissions++; return ValueTask.CompletedTask; }
33 await registry.RunTriggerAsync("test.trigger", source, context, Emit, default);
34 var resolved = received ?? throw new InvalidOperationException("触发器未收到参数");
35 Assert.Equal("mock-managed-secret", resolved.String("key")); Assert.Equal(10, resolved.Integer("seconds"));
36 Assert.Equal("fixture", source["key"]!["$secret"]!.GetValue<string>()); Assert.True(!source.ContainsKey("seconds"));
37 Assert.Equal(1, emissions); Assert.Equal(1, created); Assert.Equal(1, disposed);
38 await Assert.ThrowsAsync<ArgumentException>(() => registry.RunTriggerAsync("test.trigger", new() { ["key"] = true }, context, Emit, default));
39 await Assert.ThrowsAsync<ArgumentException>(() => registry.RunTriggerAsync("test.trigger", new(), context, Emit, default));
40 Assert.Equal(1, created);
41 using var cancellation = new CancellationTokenSource();
42 await Assert.ThrowsAsync<OperationCanceledException>(() => registry.RunTriggerAsync("test.trigger", source, context, _ =>
43 { cancellation.Cancel(); return ValueTask.CompletedTask; }, cancellation.Token));
44 Assert.Equal(2, created); Assert.Equal(2, disposed);
45 }
46
21 47 [Test]
22 48 public async Task MultipleTriggerEntriesExecuteOnlyTheirOwnBranchAndDataPorts()
23 49 {
@@ -108,4 +134,10 @@ public class TriggerNodeTests
108 134 Assert.Equal(RunStatus.Succeeded, run.Status, run.Error); Assert.Equal(originalCode, workflow.Code);
109 135 var invalid = await coordinator.RunAsync(workflow, entryNodeId: "missing-entry"); Assert.Equal(RunStatus.Failed, invalid.Status); Assert.Equal("missing-entry", invalid.ErrorNodeId);
110 136 }
137
138 private sealed class DisposableTrigger(Func<JsonObject, Func<JsonObject, ValueTask>, CancellationToken, Task> run, Action dispose) : IWorkflowTrigger, IAsyncDisposable
139 {
140 public Task RunAsync(JsonObject parameters, IWorkflowContext context, Func<JsonObject, ValueTask> emit, CancellationToken ct) => run(parameters, emit, ct);
141 public ValueTask DisposeAsync() { dispose(); return ValueTask.CompletedTask; }
142 }
111 143 }
Modified XFEWorkflow/XFEWorkflow.Test/WeComTests.cs +14 -3
@@ -121,14 +121,25 @@ public class WeComTests
121 121 var package = Path.Combine(workspace.Paths.Temp, "wecom.xfeplugin"); using (var archive = ZipFile.Open(package, ZipArchiveMode.Create))
122 122 { using (var writer = new StreamWriter(archive.CreateEntry("plugin.json").Open())) writer.Write(JsonSerializer.Serialize(manifest, WorkflowJson.Options)); archive.CreateEntryFromFile(typeof(WeComPlugin).Assembly.Location, manifest.Assembly); }
123 123 await plugins.InstallAsync(package); var transport = new FakeTransport(); await using var host = Host(workspace, transport); await Call(host, "connect");
124 var repository = new WorkflowRepository(workspace.Paths); var flow = WeComWorkflowImport.Template(); flow.Enabled = true; flow.Priority = 12; await repository.SaveAsync(flow);
125 await using var coordinator = new RunCoordinator(workspace.Paths, plugins, new(workspace.State), new(), TestSupport.FindOutput("XFEWorkflow.Runner", "XFEWorkflow.Runner.exe"));
126 coordinator.Host = async (owner, method, input, ct) => await host.InvokeAsync(owner, method, input, ct); coordinator.ReleaseHost = async owner => await host.ReleaseAsync(owner);
124 var repository = new WorkflowRepository(workspace.Paths); var flow = WeComWorkflowImport.Template(); flow.Enabled = true; flow.Priority = 12;
125 var vault = new CredentialVault(workspace.State); await vault.SetAsync("trigger-fixture", "managed-trigger-fixture-secret");
126 var parameters = flow.Graph.Nodes.Single(x => x.Trigger is not null).Trigger!.Parameters;
127 parameters["credential"] = new JsonObject { ["$secret"] = "trigger-fixture" };
128 parameters["memberIds"] = new JsonObject { ["$input"] = "test-member" }; flow.InputDefaults["test-member"] = Member; await repository.SaveAsync(flow);
129 await using var coordinator = new RunCoordinator(workspace.Paths, plugins, vault, new(), TestSupport.FindOutput("XFEWorkflow.Runner", "XFEWorkflow.Runner.exe"));
130 JsonObject? subscribed = null;
131 coordinator.Host = async (owner, method, input, ct) => { if (method == "wecom.subscribe") subscribed = WorkflowJson.Copy(input); return await host.InvokeAsync(owner, method, input, ct); }; coordinator.ReleaseHost = async owner => await host.ReleaseAsync(owner);
127 132 var done = new TaskCompletionSource<RunRecord>(TaskCreationOptions.RunContinuationsAsynchronously); coordinator.RunChanged += record => { if (record.FinishedAt is not null) done.TrySetResult(record); };
128 133 await using var scheduler = new WorkflowScheduler(repository, coordinator); await scheduler.StartAsync(); await Wait(() => transport.WatchCount == 1 && host.Status.Integer("subscriptions") == 1);
129 134 transport.Publish(Message("runner")); var run = await done.Task.WaitAsync(TimeSpan.FromSeconds(12)); Assert.Equal(RunStatus.Succeeded, run.Status, run.Error); Assert.Equal("receive", run.EntryNodeId);
130 135 Assert.Equal(1, transport.Replies.Count); Assert.True(transport.Replies.Single().QuoteOriginal); Assert.True(run.Events.Any(x => x.Kind == RunEventKind.Log && x.Message.Contains("Accepted")));
136 Assert.Equal("managed-trigger-fixture-secret", subscribed!.String("credential")); Assert.Equal(Member, subscribed!.String("memberIds")); Assert.True(!JsonSerializer.Serialize(flow, WorkflowJson.Options).Contains("managed-trigger-fixture-secret"));
131 137 await repository.SetActivationAsync(flow.Id, false); await scheduler.RefreshAsync(); Assert.Equal(0, host.Status.Integer("subscriptions")); Assert.True(!host.Status.Boolean("listening"));
138
139 flow.Graph.Nodes.Single(x => x.Trigger is not null).Trigger!.Parameters["maxAgeSeconds"] = "wrong-type";
140 await repository.SaveAsync(flow); await repository.SetActivationAsync(flow.Id, true);
141 var triggerFailure = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); scheduler.Error += error => triggerFailure.TrySetResult(error);
142 await scheduler.RefreshAsync(); Assert.True((await triggerFailure.Task.WaitAsync(TimeSpan.FromSeconds(10))).Contains("类型应为"));
132 143 }
133 144
134 145 private sealed class FakeTransport : IWeComTransport
Modified XFEWorkflow/XFEWorkflow/Controls/TriggerEditor.cs +34 -7
@@ -1,5 +1,7 @@
1 1 using System.Text.Json.Nodes;
2 using System.Globalization;
2 3 using XFEWorkflow.Abstractions;
4 using XFEWorkflow.Core.Runtime;
3 5 using Windows.Storage.Pickers;
4 6
5 7 namespace XFEWorkflow.Controls;
@@ -44,7 +46,16 @@ public sealed class TriggerEditor : StackPanel
44 46 {
45 47 try
46 48 {
47 if (errors.Count > 0) throw new ArgumentException("请修正 JSON:" + errors.First().Value);
49 if (errors.Count > 0) throw new ArgumentException("请修正参数:" + errors.First().Value);
50 foreach (var port in plugin?.Inputs ?? [])
51 {
52 var value = Definition.Parameters[port.Name];
53 var label = port.Label.Length > 0 ? port.Label : port.Name;
54 if (port.Required && value is null) throw new ArgumentException("请填写:" + label);
55 if (value is JsonObject binding && binding.Count == 1 && binding.Any(x => x.Key is "$secret" or "$input" or "$var")) continue;
56 if (value is not null && !GraphValidator.Matches(value, port.Type)) throw new ArgumentException(label + " 的类型应为 " + port.Type);
57 if (value is not null && port.Choices.Count > 0 && !port.Choices.Contains(value.Text())) throw new ArgumentException("请选择有效的" + label);
58 }
48 59 if (WorkflowTriggerNodes.ConfigurationError(Definition, catalog, workflowId) is { } message) throw new ArgumentException(message);
49 60 if (Definition.Enabled && Definition.Kind == TriggerKind.FileChanged && !Directory.Exists(Definition.Path)) throw new ArgumentException("监听目录不存在");
50 61 if (Definition.Enabled && Definition.Kind == TriggerKind.WorkflowCompleted && !workflows.Any(x => x.Id == Definition.SourceWorkflowId)) throw new ArgumentException("上游工作流不存在");
@@ -65,26 +76,42 @@ public sealed class TriggerEditor : StackPanel
65 76 private void Parameter(PortDefinition port)
66 77 {
67 78 var value = Definition.Parameters[port.Name]; var label = port.Label.Length > 0 ? port.Label : port.Name;
68 if (port.Choices.Count > 0)
79 if (port.Secret)
80 {
81 var reference = value is JsonObject binding ? binding.String("$secret") : "";
82 if (value is not null && reference.Length == 0) errors[label] = label + " 请使用设置页面保存的凭据名称";
83 Text(label + "(凭据引用)", reference, text =>
84 {
85 if (string.IsNullOrWhiteSpace(text)) Definition.Parameters.Remove(port.Name);
86 else Definition.Parameters[port.Name] = new JsonObject { ["$secret"] = text.Trim() };
87 errors.Remove(label);
88 });
89 Children.Add(new TextBlock { Text = "填写在设置 → 凭据中保存的名称,运行时读取密钥。", FontSize = 11, TextWrapping = TextWrapping.Wrap, Opacity = .7 });
90 }
91 else if (value is JsonObject binding && binding.Count == 1 && binding.Any(x => x.Key is "$input" or "$var" or "$secret"))
92 Text(label + "(绑定 JSON)", binding.ToJsonString(), text =>
93 { try { Definition.Parameters[port.Name] = JsonNode.Parse(text); errors.Remove(label); } catch (Exception ex) { errors[label] = ex.Message; } }, true);
94 else if (port.Choices.Count > 0)
69 95 {
70 96 var choice = new ComboBox { Header = label, ItemsSource = port.Choices, SelectedItem = value.Text(), HorizontalAlignment = HorizontalAlignment.Stretch };
71 97 choice.SelectionChanged += (_, _) => Definition.Parameters[port.Name] = choice.SelectedItem as string; Children.Add(choice);
72 98 }
73 99 else if (port.Type == WorkflowValueType.Boolean)
74 100 {
75 var toggle = new ToggleSwitch { Header = label, IsOn = value?.GetValue<bool>() == true, OnContent = "是", OffContent = "否" };
76 toggle.Toggled += (_, _) => Definition.Parameters[port.Name] = toggle.IsOn; Children.Add(toggle);
101 if (value is not null && !GraphValidator.Matches(value, port.Type)) errors[label] = label + " 必须为布尔值";
102 var toggle = new ToggleSwitch { Header = label, IsOn = value is JsonValue scalar && scalar.TryGetValue<bool>(out var enabled) && enabled, OnContent = "是", OffContent = "否" };
103 toggle.Toggled += (_, _) => { Definition.Parameters[port.Name] = toggle.IsOn; errors.Remove(label); }; Children.Add(toggle);
77 104 }
78 105 else if (port.Type == WorkflowValueType.Number)
79 106 {
80 var number = new NumberBox { Header = label, Value = value?.GetValue<double>() ?? 0, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact };
107 var parsed = double.TryParse(value.Text(), NumberStyles.Float, CultureInfo.InvariantCulture, out var initial) && double.IsFinite(initial);
108 if (value is not null && (!parsed || !GraphValidator.Matches(value, port.Type))) errors[label] = label + " 必须为数字";
109 var number = new NumberBox { Header = label, Value = parsed ? initial : 0, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact };
81 110 number.ValueChanged += (_, _) => { if (double.IsFinite(number.Value)) { Definition.Parameters[port.Name] = number.Value == Math.Truncate(number.Value) && number.Value >= int.MinValue && number.Value <= int.MaxValue ? JsonValue.Create((int)number.Value) : JsonValue.Create(number.Value); errors.Remove(label); } else errors[label] = label + " 必须为数字"; }; Children.Add(number);
82 111 }
83 112 else if (port.Type is WorkflowValueType.Object or WorkflowValueType.Array or WorkflowValueType.Any)
84 113 Text(label + "(JSON)", value?.ToJsonString() ?? (port.Type == WorkflowValueType.Array ? "[]" : "{}"), text =>
85 114 { try { Definition.Parameters[port.Name] = JsonNode.Parse(text); errors.Remove(label); } catch (Exception ex) { errors[label] = ex.Message; } }, true);
86 else if (port.Secret)
87 { var secret = new PasswordBox { Header = label, Password = value.Text() }; secret.PasswordChanged += (_, _) => Definition.Parameters[port.Name] = secret.Password; Children.Add(secret); }
88 115 else Text(label, value.Text(), text => Definition.Parameters[port.Name] = text, port.Name is "memberIds" or "keywords");
89 116 }
90 117 }
Modified XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.WeCom.cs +12 -0
@@ -27,6 +27,18 @@ internal static partial class UiSmokeTest
27 27 var descriptor = services.Plugins.Installed.Single(x => x.Manifest.Id == "xfe.wecom" && x.Manifest.Version == "2.0.0").Manifest.Capabilities.Single(x => x.IsTrigger);
28 28 var form = new Controls.TriggerEditor(flow.Graph.Nodes.Single(x => x.Trigger is not null).Trigger!, descriptor, [flow], flow.Id, services.Plugins.Catalog(flow), _ => { });
29 29 if (!form.Children.OfType<ComboBox>().Any(x => x.Header as string == "消息类型") || !form.Children.OfType<NumberBox>().Any() || !form.Children.OfType<ToggleSwitch>().Any(x => x.Header as string == "监听仅试运行")) throw new InvalidOperationException("触发器没有声明式参数表单");
30 var typedForm = new Controls.TriggerEditor(new() { Kind = TriggerKind.Plugin, Parameters = new() { ["enabled"] = "invalid", ["seconds"] = new System.Text.Json.Nodes.JsonArray(1) } },
31 new() { IsTrigger = true, Inputs = [new() { Name = "key", Label = "密钥", Type = WorkflowValueType.String, Secret = true }, new() { Name = "enabled", Type = WorkflowValueType.Boolean }, new() { Name = "seconds", Type = WorkflowValueType.Number }] }, [flow], flow.Id, services.Plugins.Catalog(flow), _ => { });
32 if (typedForm.Children.OfType<PasswordBox>().Any()) throw new InvalidOperationException("触发器凭据不得直接写入工作流");
33 var typedDialog = new ContentDialog { Title = "触发器参数检查", Content = new ScrollViewer { Content = typedForm, MaxHeight = 500 }, CloseButtonText = "关闭", XamlRoot = App.MainWindow.Content.XamlRoot };
34 var shown = typedDialog.ShowAsync();
35 try
36 {
37 await WaitManagementAsync(() => typedForm.IsLoaded);
38 typedForm.Children.OfType<TextBox>().Single(x => x.Header as string == "密钥(凭据引用)").Text = "test-reference";
39 await WaitManagementAsync(() => typedForm.Definition.Parameters["key"] is System.Text.Json.Nodes.JsonObject secret && secret.String("$secret") == "test-reference");
40 }
41 finally { typedDialog.Hide(); await shown; }
30 42 await Task.Delay(400); await CaptureWindowAsync(Path.Combine(output, "wecom-template-workflow.png")); await WorkflowEditorPage.CloseOpenAsync(flow.Id); navigation.NavigateTo<MainPage>(); App.MainWindow.AppWindow.Resize(size);
31 43 }
32 44 }
Modified docs/plugins.md +3 -1
@@ -19,7 +19,7 @@
19 19 }
20 20 ```
21 21
22 实际清单的 capabilities 需要声明 ID、pluginId、名称、输入输出端口,以及执行出口。能力 ID 必须以插件 ID 加点开头。输入端口的 type、label、required、defaultValue、secret、choices 生成属性表单。IsTrigger 为 true 时注册为触发器,SDK 范围必须包含当前宿主版本 1.2.0,原有 1.0.0~2.0.0 的插件仍兼容。
22 实际清单的 capabilities 需要声明 ID、pluginId、名称、输入输出端口,以及执行出口。能力 ID 必须以插件 ID 加点开头。输入端口的 type、label、required、defaultValue、secret、choices 生成属性表单。IsTrigger 为 true 时注册为触发器,SDK 范围必须包含当前宿主版本 1.3.0,原有 1.0.0~2.0.0 的插件仍兼容。
23 23
24 24 SDK 1.1 新增 `ScreenRegion` 物理像素区域模型和 `PortDefinition.EditorHint`。`screen-region`、`screen-point`、`file-open`、`file-save` 分别生成区域框选、坐标拾取、图像选择和 PNG 保存控件,支持 JSON 与数据绑定。使用这些新增 API 的插件应声明 `sdkMinimumVersion: "1.1.0"`。区域与点仍以 JSON 对象跨插件传递。
25 25
@@ -27,6 +27,8 @@ SDK 1.2 新增 `IWorkflowContext.ExecuteGraphAsync`,用于运行图形生成
27 27
28 28 插件触发能力会显示在节点库的“触发器”分类,保留插件来源名称。清单的 inputs 用于属性区的监听参数;画布端口为 `next`、`event` 和 `inputs`。事件由插件的 emit 回调发送,只有对应节点的分支会执行,插件实现无需直接调用其他工作流。
29 29
30 触发器的监听参数在 Runner 中解析 `$secret`、`$input` 与 `$var` 绑定,再应用默认值并校验必填项和类型。`$input` 读取工作流默认输入;此时尚无节点输出。标记 `secret` 的表单字段填写“设置 → 凭据”中的名称,保存为 `{"$secret":"名称"}`。监听异常回传调度状态与日志;停止时宿主也释放实现了 `IAsyncDisposable` / `IDisposable` 的触发器实例。
31
30 32 依赖使用 `id`、`minimumVersion`、`maximumVersionExclusive`;版本遵循 SemVer。supportedRids 为空表示不限制,Windows 专用插件应声明 win-x64 / win-x86 / win-arm64。
31 33
32 34 ## 实现