返回提交历史
Modified
XFEWorkflow/XFEWorkflow.Core/Runtime/CapabilityRegistry.cs
+25
-7
Modified
XFEWorkflow/XFEWorkflow.Infrastructure/Execution/RunCoordinator.cs
+4
-3
Modified
XFEWorkflow/XFEWorkflow.Runner/Program.cs
+1
-1
Modified
XFEWorkflow/XFEWorkflow.Test/TriggerNodeTests.cs
+32
-0
Modified
XFEWorkflow/XFEWorkflow.Test/WeComTests.cs
+14
-3
Modified
XFEWorkflow/XFEWorkflow/Controls/TriggerEditor.cs
+34
-7
Modified
XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.WeCom.cs
+12
-0
Modified
docs/plugins.md
+3
-1
XFEstudio/XFEWorkflow
完善触发器凭据引用、参数校验和监听失败反馈
99d2322
代码差异
8 个文件
+125
-22
@@ -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
}
@@ -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
{
@@ -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
}
@@ -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
}
@@ -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
@@ -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
}
@@ -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
}
@@ -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
## 实现