@aalis/plugin-workflow 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,27 +7,31 @@
7
7
  // ============================================================
8
8
  import { cronEngine } from '@aalis/api-cron-engine';
9
9
  import { hooks } from '@aalis/api-hooks';
10
- import { createStorageGateway, isStorageUri, storage } from '@aalis/api-storage';
10
+ import { isStorageUri, storage } from '@aalis/api-storage';
11
11
  import { tools } from '@aalis/api-tools';
12
- import { webuiServer } from '@aalis/api-webui';
12
+ import { markdownCode, webuiServer } from '@aalis/api-webui';
13
13
  import { workflow } from '@aalis/api-workflow';
14
14
  import { config, definePlugin, events, lifecycle, logger, optional, provide } from '@aalis/core';
15
+ import { configError, defineConfig, parseConfig } from '@aalis/schema-config';
15
16
  import { parse, stringify } from 'yaml';
16
17
  import { runDag, validateGraph } from './engine.js';
17
18
  import { normalizeDef, WorkflowLoader } from './loader.js';
18
19
  import { RunStore } from './persistence.js';
19
20
  import { TriggerManager } from './triggers.js';
20
- const configSchema = {
21
+ // ─── 配置 ───
22
+ const configSchema = defineConfig({
21
23
  defsDir: {
22
24
  type: 'string',
23
25
  label: '工作流定义目录',
24
26
  default: 'workspace:/workflows',
27
+ onInvalid: 'error',
25
28
  description: '加载存储下的 *.yaml 定义(storage URI);AI 通过 workflow_define 创建的定义也写入此处。',
26
29
  },
27
30
  runsFile: {
28
31
  type: 'string',
29
32
  label: '运行历史文件',
30
33
  default: 'data:/workflow-runs.json',
34
+ onInvalid: 'error',
31
35
  description: '保存最近 N 条运行实例(storage URI)。',
32
36
  },
33
37
  maxRuns: {
@@ -40,83 +44,11 @@ const configSchema = {
40
44
  type: 'boolean',
41
45
  label: '注册 AI 工具',
42
46
  default: true,
47
+ onInvalid: 'error',
43
48
  description: '开启后向 LLM 暴露 workflow_define / workflow_run 等工具。',
44
49
  },
45
- };
46
- // ─── WebUI ───
47
- const webuiPages = [
48
- {
49
- key: 'workflows',
50
- label: '工作流',
51
- icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M9 6h6"/><path d="M7.5 8.5L11 15.5"/><path d="M16.5 8.5L13 15.5"/></svg>',
52
- order: 57,
53
- content: [
54
- {
55
- type: 'stat',
56
- label: '工作流总览',
57
- source: 'workflowStats',
58
- },
59
- {
60
- type: 'table',
61
- label: '工作流定义',
62
- source: 'listWorkflowsTable',
63
- columns: [
64
- { key: 'id', label: 'ID', nowrap: true },
65
- { key: 'name', label: '名称' },
66
- { key: 'trigger', label: '触发器', nowrap: true },
67
- { key: 'nodeCount', label: '节点' },
68
- { key: 'enabled', label: '状态', nowrap: true },
69
- { key: 'description', label: '描述', maxWidth: 360, render: 'expandable-text' },
70
- ],
71
- actions: [
72
- { label: '立即运行', method: 'triggerWorkflow' },
73
- { label: '禁用/启用', method: 'toggleWorkflow' },
74
- { label: '查看 YAML', method: 'getWorkflowYaml' },
75
- { label: '删除', method: 'removeWorkflow', confirm: '确定删除该工作流?(同时删除磁盘文件)', danger: true },
76
- ],
77
- refresh: 60,
78
- },
79
- {
80
- type: 'table',
81
- label: '最近运行历史',
82
- source: 'listRunsTable',
83
- columns: [
84
- { key: 'runId', label: 'Run ID', nowrap: true },
85
- { key: 'workflowId', label: '工作流', nowrap: true },
86
- { key: 'status', label: '状态', nowrap: true },
87
- { key: 'triggerSource', label: '触发源', nowrap: true },
88
- { key: 'startedAtText', label: '开始时间', nowrap: true },
89
- { key: 'durationText', label: '耗时', nowrap: true },
90
- { key: 'error', label: '错误', maxWidth: 320, render: 'expandable-text' },
91
- ],
92
- refresh: 30,
93
- },
94
- {
95
- type: 'form',
96
- label: '新建 / 覆盖工作流(粘贴完整 YAML)',
97
- source: 'newWorkflowDraft',
98
- save: 'upsertWorkflowYaml',
99
- schema: {
100
- yaml: {
101
- type: 'textarea',
102
- label: 'YAML 定义',
103
- required: true,
104
- description: '完整 WorkflowDef YAML:id / name / trigger / nodes。trigger 类型支持 cron / interval / once / event / manual。' +
105
- '节点类型 tool / send-message / wait / agent(agent 节点派发指令给 agent 并等回复,配合 deps + {{outputs.X}} 做多智能体编排)。' +
106
- '保存后将立即注册触发器并持久化到 defsDir。',
107
- },
108
- persist: {
109
- type: 'boolean',
110
- label: '写入磁盘',
111
- default: true,
112
- description: '关闭后仅在内存中注册(进程重启后丢失)',
113
- },
114
- },
115
- },
116
- ],
117
- },
118
- ];
119
- // ─── 工具函数(与页面动作共享) ───
50
+ });
51
+ // ─── 工具函数(与工作流页共享) ───
120
52
  function triggerSummary(t) {
121
53
  switch (t.type) {
122
54
  case 'cron':
@@ -133,16 +65,6 @@ function triggerSummary(t) {
133
65
  return t.type;
134
66
  }
135
67
  }
136
- function fmtTs(ts) {
137
- if (!ts || !Number.isFinite(ts))
138
- return '—';
139
- try {
140
- return new Date(ts).toLocaleString('zh-CN', { hour12: false });
141
- }
142
- catch {
143
- return String(ts);
144
- }
145
- }
146
68
  function fmtDuration(ms) {
147
69
  if (!Number.isFinite(ms) || ms < 0)
148
70
  return '—';
@@ -155,7 +77,7 @@ function fmtDuration(ms) {
155
77
  return `${m}m${s}s`;
156
78
  }
157
79
  /**
158
- * once 记账读不出时本次运行不安排 once:定义或启用一个 once 的回执据此如实说明(AI 工具与页面动作同一口径)。
80
+ * once 记账读不出时本次运行不安排 once:定义或启用一个 once 的回执据此如实说明(AI 工具与工作流页同一口径)。
159
81
  * manualRun 是各自那一面手动执行的说法;不持久化的定义重启后就没了,「重启恢复」只对写了文件的定义成立。
160
82
  */
161
83
  function onceSkippedNote(def, persist, onceLedgerReadable, manualRun) {
@@ -163,143 +85,198 @@ function onceSkippedNote(def, persist, onceLedgerReadable, manualRun) {
163
85
  return undefined;
164
86
  return `once 记账读取失败(见日志),本次运行不会触发;可用${manualRun}手动执行${persist ? ',修复运行历史文件后重启恢复' : ''}`;
165
87
  }
88
+ const STATUS = {
89
+ pending: { text: '等待', state: 'inactive' },
90
+ running: { text: '运行中', state: 'running' },
91
+ success: { text: '成功' },
92
+ failed: { text: '失败', state: 'error' },
93
+ cancelled: { text: '已取消', state: 'inactive' },
94
+ };
166
95
  /**
167
- * 页面动作:处理函数是闭包,直接用本次激活构造的 WorkflowService,
168
- * 不再按名回查容器(查到的可能是别人提供的同名服务)。
96
+ * 工作流页:处理函数是闭包,直接用本次激活构造的 WorkflowService,不按名回查容器(查到的可能是别人提供的同名服务)。
97
+ * 返回页面的句柄:定义增删与运行起止时让它重取
169
98
  */
170
- function registerWebuiActions(webui, service, onceLedgerReadable) {
171
- webui.registerAction('workflowStats', async () => {
172
- const defs = service.listWorkflows();
173
- const enabled = defs.filter(d => d.enabled !== false).length;
174
- const runs = service.listRuns(200);
175
- const last24h = runs.filter(r => Date.now() - r.startedAt < 86_400_000);
176
- const failed24h = last24h.filter(r => r.status === 'failed' || r.status === 'cancelled').length;
177
- return {
178
- value: defs.length,
179
- hint: `启用 ${enabled} · 24h 运行 ${last24h.length} · 失败 ${failed24h}`,
180
- };
181
- });
182
- webui.registerAction('listWorkflowsTable', async () => {
183
- return service.listWorkflows().map(d => ({
184
- id: d.id,
185
- name: d.name ?? d.id,
186
- trigger: triggerSummary(d.trigger),
187
- nodeCount: d.nodes.length,
188
- enabled: d.enabled === false ? '⏸ 禁用' : '✅ 启用',
189
- description: d.description ?? '',
190
- }));
191
- });
192
- webui.registerAction('listRunsTable', async () => {
193
- return service.listRuns(50).map(r => {
194
- const dur = (r.finishedAt ?? Date.now()) - r.startedAt;
195
- const statusIcon = r.status === 'success' ? '✅' : r.status === 'failed' ? '❌' : r.status === 'cancelled' ? '⏹' : '⏳';
196
- return {
197
- runId: r.runId,
198
- workflowId: r.workflowId,
199
- status: `${statusIcon} ${r.status}`,
200
- triggerSource: r.triggerSource,
201
- startedAtText: fmtTs(r.startedAt),
202
- durationText: fmtDuration(dur),
203
- error: r.error ?? '',
204
- };
205
- });
206
- });
207
- webui.registerAction('triggerWorkflow', async (args) => {
208
- try {
209
- const run = await service.runWorkflow(String(args.id), {}, 'manual:webui');
210
- return {
211
- ok: true,
212
- runId: run.runId,
213
- status: run.status,
214
- durationMs: (run.finishedAt ?? Date.now()) - run.startedAt,
215
- error: run.error,
216
- };
217
- }
218
- catch (err) {
219
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
220
- }
221
- });
222
- webui.registerAction('toggleWorkflow', async (args) => {
223
- const def = service.getWorkflow(String(args.id));
224
- if (!def)
225
- return { ok: false, error: '工作流不存在' };
226
- const next = { ...def, enabled: def.enabled === false };
227
- try {
228
- await service.defineWorkflow(next, { persist: true });
229
- const note = onceSkippedNote(next, true, onceLedgerReadable(), '「立即运行」');
230
- return { ok: true, enabled: next.enabled, ...(note ? { message: `已启用;${note}` } : {}) };
231
- }
232
- catch (err) {
233
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
234
- }
235
- });
236
- webui.registerAction('getWorkflowYaml', async (args) => {
237
- const def = service.getWorkflow(String(args.id));
99
+ function defineWorkflowPage(webui, service, onceLedgerReadable) {
100
+ const definitionOf = (id) => {
101
+ const def = service.getWorkflow(id);
238
102
  if (!def)
239
- return { error: '工作流不存在' };
240
- return { id: def.id, yaml: stringify(def) };
241
- });
242
- webui.registerAction('removeWorkflow', async (args) => (await service.removeWorkflow(String(args.id))) ? { ok: true } : { ok: false, error: '工作流不存在' });
243
- webui.registerAction('newWorkflowDraft', async () => ({
244
- yaml: `id: my-workflow
245
- name: 示例工作流
246
- description: 描述这个工作流的作用
247
- trigger:
248
- type: manual
249
- vars:
250
- greeting: hello
251
- nodes:
252
- - id: say
253
- type: send-message
254
- sessionId: internal:demo
255
- platform: internal
256
- content: "{{vars.greeting}}, world!"
257
- `,
258
- persist: true,
259
- }));
260
- webui.registerAction('upsertWorkflowYaml', async (args) => {
261
- const yamlText = String(args.yaml ?? '').trim();
262
- if (!yamlText)
263
- return { ok: false, error: 'yaml 不能为空' };
264
- let raw;
265
- try {
266
- raw = parse(yamlText);
267
- }
268
- catch (err) {
269
- return { ok: false, error: `YAML 解析失败: ${err instanceof Error ? err.message : err}` };
270
- }
271
- const def = normalizeDef(raw, `wf-${Date.now()}`);
272
- if (!def)
273
- return { ok: false, error: '定义不合法:缺少 trigger 或 nodes' };
274
- const persist = args.persist !== false;
275
- try {
276
- await service.defineWorkflow(def, { persist });
277
- const note = onceSkippedNote(def, persist, onceLedgerReadable(), '「立即运行」');
278
- return { ok: true, id: def.id, nodes: def.nodes.length, ...(note ? { message: `已保存;${note}` } : {}) };
279
- }
280
- catch (err) {
281
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
282
- }
103
+ throw new Error(`工作流 ${id} 不存在`);
104
+ return def;
105
+ };
106
+ /** 启用或停用:改的是写在磁盘上的定义 */
107
+ const setEnabled = async (id, enabled) => {
108
+ const next = { ...definitionOf(id), enabled };
109
+ await service.defineWorkflow(next, { persist: true });
110
+ const note = onceSkippedNote(next, true, onceLedgerReadable(), '「立即运行」');
111
+ return `已${enabled ? '启用' : '停用'} ${id}${note ? `;${note}` : ''}`;
112
+ };
113
+ return webui.definePage({
114
+ key: 'workflows',
115
+ title: '工作流',
116
+ icon: 'workflow',
117
+ actions: {
118
+ runWorkflow: {
119
+ label: '立即运行',
120
+ effect: 'write',
121
+ row: true,
122
+ async handler({ rowKey }) {
123
+ const run = await service.runWorkflow(rowKey, {}, 'manual:webui');
124
+ if (run.status !== 'success') {
125
+ throw new Error(`运行没有成功(${STATUS[run.status].text})${run.error ? `:${run.error}` : ''}`);
126
+ }
127
+ return `已运行 ${rowKey},用时 ${fmtDuration((run.finishedAt ?? Date.now()) - run.startedAt)}`;
128
+ },
129
+ },
130
+ enableWorkflow: { label: '启用', effect: 'write', row: true, handler: ({ rowKey }) => setEnabled(rowKey, true) },
131
+ disableWorkflow: {
132
+ label: '停用',
133
+ effect: 'write',
134
+ row: true,
135
+ handler: ({ rowKey }) => setEnabled(rowKey, false),
136
+ },
137
+ removeWorkflow: {
138
+ label: '删除',
139
+ effect: 'destructive',
140
+ confirm: '删除这个工作流,连同磁盘上的定义文件?',
141
+ row: true,
142
+ async handler({ rowKey }) {
143
+ if (!(await service.removeWorkflow(rowKey)))
144
+ throw new Error(`工作流 ${rowKey} 不存在`);
145
+ return `已删除 ${rowKey}`;
146
+ },
147
+ },
148
+ defineWorkflow: {
149
+ label: '新建或覆盖',
150
+ effect: 'write',
151
+ input: {
152
+ yaml: {
153
+ type: 'textarea',
154
+ label: 'YAML 定义',
155
+ required: true,
156
+ description: '完整的定义:id、name、trigger、nodes。trigger 支持 cron、interval、once、event、manual;节点支持 tool、send-message、' +
157
+ 'wait、agent(派发指令给 agent 并等回复,配合 deps 与 {{outputs.X}} 做多智能体编排)。同 id 的定义被覆盖,' +
158
+ '保存后立即注册触发器。现有定义的 YAML 在定义表格的详情里,可以照着改。',
159
+ },
160
+ persist: {
161
+ type: 'boolean',
162
+ label: '写入磁盘',
163
+ default: true,
164
+ description: '关闭后只在内存里注册,重启后丢失',
165
+ },
166
+ },
167
+ async handler({ input }) {
168
+ let raw;
169
+ try {
170
+ raw = parse(input.yaml);
171
+ }
172
+ catch (err) {
173
+ throw new Error(`YAML 解析失败:${err instanceof Error ? err.message : String(err)}`);
174
+ }
175
+ const def = normalizeDef(raw, `wf-${Date.now()}`);
176
+ if (!def)
177
+ throw new Error('定义不合法:缺少 trigger 或 nodes');
178
+ await service.defineWorkflow(def, { persist: input.persist });
179
+ const note = onceSkippedNote(def, input.persist, onceLedgerReadable(), '「立即运行」');
180
+ return `已保存工作流 ${def.id}${note ? `;${note}` : ''}`;
181
+ },
182
+ },
183
+ },
184
+ content: [
185
+ {
186
+ type: 'info',
187
+ id: 'summary',
188
+ data() {
189
+ const defs = service.listWorkflows();
190
+ const recent = service.listRuns(200).filter(r => Date.now() - r.startedAt < 86_400_000);
191
+ return [
192
+ { label: '定义', value: defs.length },
193
+ { label: '启用', value: defs.filter(d => d.enabled !== false).length },
194
+ { label: '24 小时运行', value: recent.length },
195
+ {
196
+ label: '24 小时失败',
197
+ value: recent.filter(r => r.status === 'failed' || r.status === 'cancelled').length,
198
+ },
199
+ ];
200
+ },
201
+ },
202
+ {
203
+ type: 'table',
204
+ id: 'workflows',
205
+ label: '定义',
206
+ rowKey: 'id',
207
+ empty: '还没有工作流',
208
+ columns: [
209
+ { key: 'id', label: 'ID', kind: 'code' },
210
+ { key: 'name', label: '名称', primary: true },
211
+ { key: 'trigger', label: '触发' },
212
+ { key: 'nodes', label: '节点数' },
213
+ { key: 'status', label: '状态', kind: 'status', primary: true },
214
+ { key: 'description', label: '描述', kind: 'long-text' },
215
+ ],
216
+ rowActions: [
217
+ { action: 'runWorkflow' },
218
+ { action: 'enableWorkflow', visibleWhen: 'disabled' },
219
+ { action: 'disableWorkflow', visibleWhen: 'enabled' },
220
+ { action: 'removeWorkflow' },
221
+ ],
222
+ data: () => service.listWorkflows().map(d => ({
223
+ id: d.id,
224
+ name: d.name ?? d.id,
225
+ trigger: triggerSummary(d.trigger),
226
+ nodes: d.nodes.length,
227
+ status: d.enabled === false ? { text: '停用', state: 'inactive' } : { text: '启用' },
228
+ enabled: d.enabled !== false,
229
+ disabled: d.enabled === false,
230
+ description: d.description ?? '',
231
+ })),
232
+ detail: [
233
+ {
234
+ type: 'markdown',
235
+ id: 'yaml',
236
+ label: 'YAML',
237
+ data: ({ rowKey }) => markdownCode(stringify(definitionOf(rowKey)), 'yaml'),
238
+ },
239
+ ],
240
+ },
241
+ {
242
+ type: 'table',
243
+ id: 'runs',
244
+ label: '最近运行',
245
+ rowKey: 'runId',
246
+ empty: '还没有运行过',
247
+ columns: [
248
+ { key: 'workflowId', label: '工作流', primary: true },
249
+ { key: 'status', label: '状态', kind: 'status', primary: true },
250
+ { key: 'triggerSource', label: '触发来源' },
251
+ { key: 'startedAt', label: '开始时间', kind: 'time' },
252
+ { key: 'duration', label: '用时' },
253
+ { key: 'error', label: '错误', kind: 'long-text' },
254
+ { key: 'runId', label: '运行 ID', kind: 'code' },
255
+ ],
256
+ data: () => service.listRuns(50).map(r => ({
257
+ runId: r.runId,
258
+ workflowId: r.workflowId,
259
+ status: STATUS[r.status],
260
+ triggerSource: r.triggerSource,
261
+ startedAt: r.startedAt,
262
+ duration: fmtDuration((r.finishedAt ?? Date.now()) - r.startedAt),
263
+ error: r.error ?? '',
264
+ })),
265
+ },
266
+ { type: 'actions', actions: ['defineWorkflow'] },
267
+ ],
283
268
  });
284
269
  }
285
- /** 路径配置只接受 storage URI;未设、留空或非字符串用默认值,其它写法(如相对路径 workspace/workflows)拒绝激活。 */
270
+ /** 路径配置只接受 storage URI;留空用默认值,其它写法(如相对路径 workspace/workflows)拒绝激活。 */
286
271
  function resolveUri(key, input, fallback) {
287
- const s = typeof input === 'string' ? input.trim() : '';
272
+ const s = input.trim();
288
273
  if (!s)
289
274
  return fallback;
290
275
  if (!isStorageUri(s)) {
291
- throw new Error(`plugin-workflow 配置错误: ${key}="${s}" 不是 storage URI,请写成 <根名>:/<路径>(如 ${fallback}),或删掉该键使用默认值`);
276
+ throw configError(`${key}="${s}" 不是 storage URI,请写成 <根名>:/<路径>(如 ${fallback}),或删掉该键使用默认值`);
292
277
  }
293
278
  return s;
294
279
  }
295
- function resolveConfig(raw) {
296
- return {
297
- defsDir: resolveUri('defsDir', raw.defsDir, 'workspace:/workflows'),
298
- runsFile: resolveUri('runsFile', raw.runsFile, 'data:/workflow-runs.json'),
299
- maxRuns: typeof raw.maxRuns === 'number' && raw.maxRuns > 0 ? raw.maxRuns : 200,
300
- enableTools: raw.enableTools !== false,
301
- };
302
- }
303
280
  // ─── 插件入口 ───
304
281
  const uses = {
305
282
  /** cron / interval 触发器共享的整分钟 tick 与表达式解析 */
@@ -308,7 +285,7 @@ const uses = {
308
285
  storage: optional(storage),
309
286
  /** tool 节点的执行入口,兼 AI 工具面的登记口 */
310
287
  tools: optional(tools),
311
- /** 工作流页与页面动作的登记口;无 WebUI 时引擎照常运行 */
288
+ /** 工作流页的登记口;无 WebUI 时引擎照常运行 */
312
289
  webui: optional(webuiServer),
313
290
  events,
314
291
  /** agent 节点经 'agent:turn:after' 捕获本轮回复 */
@@ -332,16 +309,24 @@ export default definePlugin({
332
309
  });
333
310
  async function run(caps) {
334
311
  const { events, hooks, lifecycle, provide, tools, webui } = caps;
335
- const config = resolveConfig(caps.config);
336
- const logger = caps.logger.child('workflow');
337
- const storageGateway = createStorageGateway(caps.storage);
338
- const loader = new WorkflowLoader(storageGateway, config.defsDir, logger);
312
+ const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
313
+ const config = {
314
+ ...cfg,
315
+ defsDir: resolveUri('defsDir', cfg.defsDir, configSchema.defsDir.default),
316
+ runsFile: resolveUri('runsFile', cfg.runsFile, configSchema.runsFile.default),
317
+ maxRuns: cfg.maxRuns > 0 ? cfg.maxRuns : configSchema.maxRuns.default,
318
+ };
319
+ const logger = caps.logger.use.child('workflow');
320
+ const loader = new WorkflowLoader(caps.storage.use, config.defsDir, logger);
339
321
  const scanned = await loader.loadAll();
340
- const runStore = new RunStore(storageGateway, config.runsFile, config.maxRuns, logger);
322
+ const runStore = new RunStore(caps.storage.use, config.runsFile, config.maxRuns, logger);
341
323
  await runStore.init();
342
- const cancelTokens = new Map();
324
+ /** 在飞 run 的取消开关,按 runId */
325
+ const aborts = new Map();
326
+ /** 在飞的 run:拆卸先中止,再等它们收尾,被取消的 run 也要把终态写进运行历史 */
327
+ const running = new Set();
343
328
  // ── 触发管理器:内部 cron/interval/event 监听 ──
344
- const triggers = new TriggerManager({ cronEngine: caps.cronEngine, events, logger }, (workflowId, source, payload) => {
329
+ const triggers = new TriggerManager({ cronEngine: caps.cronEngine, events: events.use, logger }, (workflowId, source, payload) => {
345
330
  // 异步触发,不阻塞触发源;event 触发的 payload({args})作为 vars 注入,{{vars.X}} 才读得到
346
331
  runById(workflowId, payload ?? {}, source).catch(err => {
347
332
  logger.error(`触发执行 workflow=${workflowId} source=${source} 失败: ${err instanceof Error ? err.message : err}`);
@@ -354,15 +339,22 @@ async function run(caps) {
354
339
  for (const def of loader.list())
355
340
  triggers.register(def);
356
341
  // ── 订阅外部 trigger:fired(来自 scheduler / 其他触发源)──
357
- events.on('trigger:fired', info => {
342
+ events.use.on('trigger:fired', info => {
358
343
  if (!info?.workflowId)
359
344
  return;
360
345
  runById(info.workflowId, info.payload ?? {}, info.source ?? `trigger:${info.type}`).catch(err => {
361
346
  logger.error(`trigger:fired workflow=${info.workflowId} 执行失败: ${err instanceof Error ? err.message : err}`);
362
347
  });
363
348
  });
364
- // ── 核心:跑一次 workflow ──
365
- async function runById(workflowId, extraVars, triggerSource, caller) {
349
+ // ── 核心:跑一次 workflow(登记进 running,拆卸据此等它收尾)──
350
+ function runById(...args) {
351
+ const pending = execute(...args);
352
+ running.add(pending);
353
+ const settle = () => running.delete(pending);
354
+ pending.then(settle, settle);
355
+ return pending;
356
+ }
357
+ async function execute(workflowId, extraVars, triggerSource, caller) {
366
358
  const def = loader.get(workflowId);
367
359
  if (!def)
368
360
  throw new Error(`workflow "${workflowId}" 不存在`);
@@ -370,8 +362,8 @@ async function run(caps) {
370
362
  throw new Error(`workflow "${workflowId}" 已禁用`);
371
363
  const runId = `${workflowId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
372
364
  const vars = { ...(def.vars ?? {}), ...extraVars };
373
- const cancelToken = { cancelled: false };
374
- cancelTokens.set(runId, cancelToken);
365
+ const abort = new AbortController();
366
+ aborts.set(runId, abort);
375
367
  const run = {
376
368
  runId,
377
369
  workflowId,
@@ -382,13 +374,14 @@ async function run(caps) {
382
374
  nodes: def.nodes.map(n => ({ id: n.id, type: n.type, status: 'pending' })),
383
375
  };
384
376
  runStore.add(run);
385
- await events.emit('workflow:run:start', run);
377
+ page.invalidate();
378
+ await events.use.emit('workflow:run:start', run);
386
379
  logger.info(`run=${runId} 启动 (trigger=${triggerSource})`);
387
380
  try {
388
381
  const result = await runDag({
389
382
  tools,
390
- events,
391
- hooks,
383
+ events: events.use,
384
+ hooks: hooks.use,
392
385
  logger,
393
386
  def,
394
387
  runId,
@@ -396,19 +389,19 @@ async function run(caps) {
396
389
  // 运行时身份:经 workflow_run 工具触发时以 actor 透传【调用者】授权身份,使工作流
397
390
  // 内部工具与 agent/send_message 节点按【调用者】的权限等级过 authority 闸(而非匿名
398
391
  // level-0);owner 定义、谁调按谁裁决,杜绝借他人 workflow 提权。platform 保持
399
- // 'workflow' 会话语义(不再借调用者平台——那会污染定时任务归属/confirm 选路等分流)。
392
+ // 'workflow' 会话语义(不再借调用者平台——那会污染定时任务归属/审批的发起类别等分流)。
400
393
  // cron/event/webui 触发无调用者 → 保持匿名(仅能跑 public 工具)。
401
394
  toolCallContext: {
402
395
  sessionId: `workflow::${workflowId}`,
403
396
  platform: 'workflow',
404
397
  actor: caller?.platform && caller?.userId ? { platform: caller.platform, userId: caller.userId } : undefined,
405
398
  },
406
- cancelToken,
399
+ signal: abort.signal,
407
400
  onNodeDone: (info) => {
408
401
  const idx = run.nodes.findIndex(n => n.id === info.id);
409
402
  if (idx >= 0)
410
403
  run.nodes[idx] = info;
411
- events.emit('workflow:node:done', { runId, node: info }).catch(() => { });
404
+ events.use.emit('workflow:node:done', { runId, node: info }).catch(() => { });
412
405
  },
413
406
  });
414
407
  run.status = result.status;
@@ -417,11 +410,11 @@ async function run(caps) {
417
410
  run.finishedAt = Date.now();
418
411
  runStore.update(run);
419
412
  if (result.status === 'success') {
420
- await events.emit('workflow:run:done', run);
413
+ await events.use.emit('workflow:run:done', run);
421
414
  logger.info(`run=${runId} 完成 (${run.finishedAt - run.startedAt}ms)`);
422
415
  }
423
416
  else {
424
- await events.emit('workflow:run:error', run);
417
+ await events.use.emit('workflow:run:error', run);
425
418
  logger.warn(`run=${runId} ${result.status}: ${result.error ?? ''}`);
426
419
  }
427
420
  }
@@ -430,11 +423,12 @@ async function run(caps) {
430
423
  run.error = err instanceof Error ? err.message : String(err);
431
424
  run.finishedAt = Date.now();
432
425
  runStore.update(run);
433
- await events.emit('workflow:run:error', run);
426
+ await events.use.emit('workflow:run:error', run);
434
427
  logger.error(`run=${runId} 异常: ${run.error}`);
435
428
  }
436
429
  finally {
437
- cancelTokens.delete(runId);
430
+ aborts.delete(runId);
431
+ page.invalidate();
438
432
  }
439
433
  return run;
440
434
  }
@@ -458,6 +452,7 @@ async function run(caps) {
458
452
  loader.putMemory(def);
459
453
  }
460
454
  triggers.register(def);
455
+ page.invalidate();
461
456
  logger.info(`workflow 已定义: ${def.id} (持久化=${opts?.persist !== false})`);
462
457
  },
463
458
  async removeWorkflow(id) {
@@ -465,16 +460,17 @@ async function run(caps) {
465
460
  const had = await loader.removeDef(id);
466
461
  triggers.unregister(id);
467
462
  runStore.clearOnceFired(id); // 定义没了,once 记账不留孤儿:同 id 重建算新工作流
463
+ page.invalidate();
468
464
  return had;
469
465
  },
470
466
  async runWorkflow(id, vars, source, caller) {
471
467
  return await runById(id, vars ?? {}, source ?? 'manual', caller);
472
468
  },
473
469
  cancelRun(runId) {
474
- const t = cancelTokens.get(runId);
475
- if (!t)
470
+ const abort = aborts.get(runId);
471
+ if (!abort)
476
472
  return false;
477
- t.cancelled = true;
473
+ abort.abort();
478
474
  return true;
479
475
  },
480
476
  getRun(runId) {
@@ -484,25 +480,23 @@ async function run(caps) {
484
480
  return runStore.list(limit, workflowId);
485
481
  },
486
482
  };
487
- provide(workflow, service);
483
+ provide.use(workflow, service);
484
+ const page = defineWorkflowPage(webui.use, service, () => runStore.onceLedgerReadable());
488
485
  // ── AI 工具 ──
489
486
  // tools 晚上线也无妨:登记口在提供者缺席时先挂账,上线后自动补挂
490
487
  if (config.enableTools) {
491
- registerTools(tools, service, () => runStore.onceLedgerReadable());
488
+ registerTools(tools.use, service, () => runStore.onceLedgerReadable());
492
489
  }
493
- // ── WebUI ──
494
- for (const page of webuiPages)
495
- webui.registerPage(page);
496
- registerWebuiActions(webui, service, () => runStore.onceLedgerReadable());
497
490
  // ── 清理 ──
498
- lifecycle.onDispose(async () => {
491
+ lifecycle.use.onDispose(async () => {
499
492
  triggers.dispose();
500
- // 先置 cancelled 再清表:只 clear 的话在飞 run 仍持有自己的 token 引用,会继续跑完剩余
501
- // 节点、发事件,并把结果写回旧 RunStore 的历史快照
502
- for (const t of cancelTokens.values())
503
- t.cancelled = true;
504
- cancelTokens.clear();
505
- // 等排队中的运行历史 / once 记账写完再结束拆卸:否则 app.stop() 返回后仍有落盘在飞
493
+ // 中止全部在飞 run:不再调度新节点,阻塞中的 wait / agent 节点立即结束。再等它们收尾、排队中的运行历史 /
494
+ // once 记账写完才结束拆卸:否则 app.stop() 返回后仍有落盘在飞,bounce 时迟到的快照会盖掉新实例的记账。
495
+ // 不响应中止信号的工具节点至多让这里等到 disposeTimeoutMs;放弃等待后旧实例记为 lingering,同 id 的新实例要等那次
496
+ // run 真正落定、这里返回之后才起
497
+ for (const abort of aborts.values())
498
+ abort.abort();
499
+ await Promise.allSettled(running);
506
500
  await runStore.flushed();
507
501
  });
508
502
  logger.info(`工作流插件已启动 (${loader.list().length} 个定义已加载)`);