@aalis/plugin-workflow 0.1.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/engine.d.ts +32 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +218 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +647 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +23 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +112 -0
- package/dist/loader.js.map +1 -0
- package/dist/persistence.d.ts +22 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +71 -0
- package/dist/persistence.js.map +1 -0
- package/dist/triggers.d.ts +19 -0
- package/dist/triggers.d.ts.map +1 -0
- package/dist/triggers.js +139 -0
- package/dist/triggers.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// @aalis/plugin-workflow — DAG 工作流引擎
|
|
3
|
+
//
|
|
4
|
+
// 订阅 'trigger:fired' 事件 + 内置 cron/interval/event 触发器,
|
|
5
|
+
// 按 DAG 执行节点,结果存 data/workflow-runs.json,
|
|
6
|
+
// 定义存 workspace/workflows/*.yaml。
|
|
7
|
+
// ============================================================
|
|
8
|
+
import { createStorageGateway } from '@aalis/plugin-storage-api';
|
|
9
|
+
import { useToolService } from '@aalis/plugin-tools-api';
|
|
10
|
+
// 引入 plugin-webui-api 的副作用以激活 PluginModule.extends/subsystem 类型增广
|
|
11
|
+
import '@aalis/plugin-webui-api';
|
|
12
|
+
import { useWebuiService } from '@aalis/plugin-webui-api';
|
|
13
|
+
import { parse, stringify } from 'yaml';
|
|
14
|
+
import { runDag, validateGraph } from './engine.js';
|
|
15
|
+
import { normalizeDef, WorkflowLoader } from './loader.js';
|
|
16
|
+
import { RunStore } from './persistence.js';
|
|
17
|
+
import { TriggerManager } from './triggers.js';
|
|
18
|
+
// ─── 元数据 ───
|
|
19
|
+
export const name = '@aalis/plugin-workflow';
|
|
20
|
+
export const displayName = '工作流';
|
|
21
|
+
export const subsystem = 'workflow';
|
|
22
|
+
export const provides = ['workflow'];
|
|
23
|
+
export const inject = {
|
|
24
|
+
required: ['cron-engine'],
|
|
25
|
+
optional: ['tools', 'storage', 'webui'],
|
|
26
|
+
};
|
|
27
|
+
export const extends_ = {
|
|
28
|
+
events: ['workflow:run:start', 'workflow:run:done', 'workflow:run:error', 'workflow:node:done', 'trigger:fired'],
|
|
29
|
+
};
|
|
30
|
+
export const configSchema = {
|
|
31
|
+
defsDir: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
label: '工作流定义目录',
|
|
34
|
+
default: 'workspace:/workflows',
|
|
35
|
+
description: '加载存储下的 *.yaml 定义(storage URI,也兼容旧【workspace/workflows】);AI 通过 workflow_define 创建的定义也写入此处。',
|
|
36
|
+
},
|
|
37
|
+
runsFile: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
label: '运行历史文件',
|
|
40
|
+
default: 'data:/workflow-runs.json',
|
|
41
|
+
description: '保存最近 N 条运行实例(storage URI,也兼容旧【data/workflow-runs.json】)。',
|
|
42
|
+
},
|
|
43
|
+
maxRuns: {
|
|
44
|
+
type: 'number',
|
|
45
|
+
label: '保留最近运行条数',
|
|
46
|
+
default: 200,
|
|
47
|
+
description: '超过则按时间裁剪最旧的;最小 10。',
|
|
48
|
+
},
|
|
49
|
+
enableTools: {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
label: '注册 AI 工具',
|
|
52
|
+
default: true,
|
|
53
|
+
description: '开启后向 LLM 暴露 workflow_define / workflow_run 等工具。',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export const defaultConfig = {
|
|
57
|
+
defsDir: 'workspace:/workflows',
|
|
58
|
+
runsFile: 'data:/workflow-runs.json',
|
|
59
|
+
maxRuns: 200,
|
|
60
|
+
enableTools: true,
|
|
61
|
+
};
|
|
62
|
+
// ─── WebUI ───
|
|
63
|
+
const webuiPages = [
|
|
64
|
+
{
|
|
65
|
+
key: 'workflows',
|
|
66
|
+
label: '工作流',
|
|
67
|
+
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>',
|
|
68
|
+
order: 57,
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: 'stat',
|
|
72
|
+
label: '工作流总览',
|
|
73
|
+
source: 'workflowStats',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'table',
|
|
77
|
+
label: '工作流定义',
|
|
78
|
+
source: 'listWorkflowsTable',
|
|
79
|
+
columns: [
|
|
80
|
+
{ key: 'id', label: 'ID', nowrap: true },
|
|
81
|
+
{ key: 'name', label: '名称' },
|
|
82
|
+
{ key: 'trigger', label: '触发器', nowrap: true },
|
|
83
|
+
{ key: 'nodeCount', label: '节点' },
|
|
84
|
+
{ key: 'enabled', label: '状态', nowrap: true },
|
|
85
|
+
{ key: 'description', label: '描述', maxWidth: 360, render: 'expandable-text' },
|
|
86
|
+
],
|
|
87
|
+
actions: [
|
|
88
|
+
{ label: '立即运行', method: 'triggerWorkflow' },
|
|
89
|
+
{ label: '禁用/启用', method: 'toggleWorkflow' },
|
|
90
|
+
{ label: '查看 YAML', method: 'getWorkflowYaml' },
|
|
91
|
+
{ label: '删除', method: 'removeWorkflow', confirm: '确定删除该工作流?(同时删除磁盘文件)', danger: true },
|
|
92
|
+
],
|
|
93
|
+
refresh: 60,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: 'table',
|
|
97
|
+
label: '最近运行历史',
|
|
98
|
+
source: 'listRunsTable',
|
|
99
|
+
columns: [
|
|
100
|
+
{ key: 'runId', label: 'Run ID', nowrap: true },
|
|
101
|
+
{ key: 'workflowId', label: '工作流', nowrap: true },
|
|
102
|
+
{ key: 'status', label: '状态', nowrap: true },
|
|
103
|
+
{ key: 'triggerSource', label: '触发源', nowrap: true },
|
|
104
|
+
{ key: 'startedAtText', label: '开始时间', nowrap: true },
|
|
105
|
+
{ key: 'durationText', label: '耗时', nowrap: true },
|
|
106
|
+
{ key: 'error', label: '错误', maxWidth: 320, render: 'expandable-text' },
|
|
107
|
+
],
|
|
108
|
+
refresh: 30,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'form',
|
|
112
|
+
label: '新建 / 覆盖工作流(粘贴完整 YAML)',
|
|
113
|
+
source: 'newWorkflowDraft',
|
|
114
|
+
save: 'upsertWorkflowYaml',
|
|
115
|
+
schema: {
|
|
116
|
+
yaml: {
|
|
117
|
+
type: 'textarea',
|
|
118
|
+
label: 'YAML 定义',
|
|
119
|
+
required: true,
|
|
120
|
+
description: '完整 WorkflowDef YAML:id / name / trigger / nodes。trigger 类型支持 cron / interval / once / event / manual。保存后将立即注册触发器并持久化到 defsDir。',
|
|
121
|
+
},
|
|
122
|
+
persist: {
|
|
123
|
+
type: 'boolean',
|
|
124
|
+
label: '写入磁盘',
|
|
125
|
+
default: true,
|
|
126
|
+
description: '关闭后仅在内存中注册(进程重启后丢失)',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
// ─── 工具函数(与 actions 共享) ───
|
|
134
|
+
function triggerSummary(t) {
|
|
135
|
+
switch (t.type) {
|
|
136
|
+
case 'cron':
|
|
137
|
+
return `cron ${t.expr ?? ''}`.trim();
|
|
138
|
+
case 'interval':
|
|
139
|
+
return `每 ${t.seconds ?? '?'}s`;
|
|
140
|
+
case 'once':
|
|
141
|
+
return `一次性@${t.runAt ?? '?'}`;
|
|
142
|
+
case 'event':
|
|
143
|
+
return `event:${t.event ?? '?'}`;
|
|
144
|
+
case 'manual':
|
|
145
|
+
return '手动';
|
|
146
|
+
default:
|
|
147
|
+
return t.type;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function fmtTs(ts) {
|
|
151
|
+
if (!ts || !Number.isFinite(ts))
|
|
152
|
+
return '—';
|
|
153
|
+
try {
|
|
154
|
+
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return String(ts);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function fmtDuration(ms) {
|
|
161
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
162
|
+
return '—';
|
|
163
|
+
if (ms < 1000)
|
|
164
|
+
return `${ms}ms`;
|
|
165
|
+
if (ms < 60_000)
|
|
166
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
167
|
+
const m = Math.floor(ms / 60_000);
|
|
168
|
+
const s = Math.floor((ms % 60_000) / 1000);
|
|
169
|
+
return `${m}m${s}s`;
|
|
170
|
+
}
|
|
171
|
+
export const actions = {
|
|
172
|
+
async workflowStats(ctx) {
|
|
173
|
+
const svc = ctx.getService('workflow');
|
|
174
|
+
if (!svc)
|
|
175
|
+
return { value: 0, hint: 'workflow 服务未就绪' };
|
|
176
|
+
const defs = svc.listWorkflows();
|
|
177
|
+
const enabled = defs.filter(d => d.enabled !== false).length;
|
|
178
|
+
const runs = svc.listRuns(200);
|
|
179
|
+
const last24h = runs.filter(r => Date.now() - r.startedAt < 86_400_000);
|
|
180
|
+
const failed24h = last24h.filter(r => r.status === 'failed' || r.status === 'cancelled').length;
|
|
181
|
+
return {
|
|
182
|
+
value: defs.length,
|
|
183
|
+
hint: `启用 ${enabled} · 24h 运行 ${last24h.length} · 失败 ${failed24h}`,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
async listWorkflowsTable(ctx) {
|
|
187
|
+
const svc = ctx.getService('workflow');
|
|
188
|
+
if (!svc)
|
|
189
|
+
return [];
|
|
190
|
+
const runs = svc.listRuns(200);
|
|
191
|
+
const lastByWf = new Map();
|
|
192
|
+
for (const r of runs) {
|
|
193
|
+
const prev = lastByWf.get(r.workflowId) ?? 0;
|
|
194
|
+
if (r.startedAt > prev)
|
|
195
|
+
lastByWf.set(r.workflowId, r.startedAt);
|
|
196
|
+
}
|
|
197
|
+
return svc.listWorkflows().map(d => ({
|
|
198
|
+
id: d.id,
|
|
199
|
+
name: d.name ?? d.id,
|
|
200
|
+
trigger: triggerSummary(d.trigger),
|
|
201
|
+
nodeCount: d.nodes.length,
|
|
202
|
+
enabled: d.enabled === false ? '⏸ 禁用' : '✅ 启用',
|
|
203
|
+
description: d.description ?? '',
|
|
204
|
+
lastRun: lastByWf.get(d.id) ?? 0,
|
|
205
|
+
}));
|
|
206
|
+
},
|
|
207
|
+
async listRunsTable(ctx) {
|
|
208
|
+
const svc = ctx.getService('workflow');
|
|
209
|
+
if (!svc)
|
|
210
|
+
return [];
|
|
211
|
+
return svc.listRuns(50).map(r => {
|
|
212
|
+
const dur = (r.finishedAt ?? Date.now()) - r.startedAt;
|
|
213
|
+
const statusIcon = r.status === 'success' ? '✅' : r.status === 'failed' ? '❌' : r.status === 'cancelled' ? '⏹' : '⏳';
|
|
214
|
+
return {
|
|
215
|
+
runId: r.runId,
|
|
216
|
+
workflowId: r.workflowId,
|
|
217
|
+
status: `${statusIcon} ${r.status}`,
|
|
218
|
+
triggerSource: r.triggerSource,
|
|
219
|
+
startedAtText: fmtTs(r.startedAt),
|
|
220
|
+
durationText: fmtDuration(dur),
|
|
221
|
+
error: r.error ?? '',
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
async triggerWorkflow(ctx, args) {
|
|
226
|
+
const svc = ctx.getService('workflow');
|
|
227
|
+
if (!svc)
|
|
228
|
+
return { ok: false, error: 'workflow 服务未就绪' };
|
|
229
|
+
try {
|
|
230
|
+
const run = await svc.runWorkflow(String(args.id), {}, 'manual:webui');
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
runId: run.runId,
|
|
234
|
+
status: run.status,
|
|
235
|
+
durationMs: (run.finishedAt ?? Date.now()) - run.startedAt,
|
|
236
|
+
error: run.error,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
async toggleWorkflow(ctx, args) {
|
|
244
|
+
const svc = ctx.getService('workflow');
|
|
245
|
+
if (!svc)
|
|
246
|
+
return { ok: false, error: 'workflow 服务未就绪' };
|
|
247
|
+
const def = svc.getWorkflow(String(args.id));
|
|
248
|
+
if (!def)
|
|
249
|
+
return { ok: false, error: '工作流不存在' };
|
|
250
|
+
const next = { ...def, enabled: def.enabled === false };
|
|
251
|
+
try {
|
|
252
|
+
await svc.defineWorkflow(next, { persist: true });
|
|
253
|
+
return { ok: true, enabled: next.enabled };
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
async getWorkflowYaml(ctx, args) {
|
|
260
|
+
const svc = ctx.getService('workflow');
|
|
261
|
+
if (!svc)
|
|
262
|
+
return { error: 'workflow 服务未就绪' };
|
|
263
|
+
const def = svc.getWorkflow(String(args.id));
|
|
264
|
+
if (!def)
|
|
265
|
+
return { error: '工作流不存在' };
|
|
266
|
+
return { id: def.id, yaml: stringify(def) };
|
|
267
|
+
},
|
|
268
|
+
async removeWorkflow(ctx, args) {
|
|
269
|
+
const svc = ctx.getService('workflow');
|
|
270
|
+
if (!svc)
|
|
271
|
+
return { ok: false, error: 'workflow 服务未就绪' };
|
|
272
|
+
const ok = await svc.removeWorkflow(String(args.id));
|
|
273
|
+
return { ok, message: ok ? '已删除' : '不存在' };
|
|
274
|
+
},
|
|
275
|
+
async newWorkflowDraft() {
|
|
276
|
+
return {
|
|
277
|
+
yaml: `id: my-workflow
|
|
278
|
+
name: 示例工作流
|
|
279
|
+
description: 描述这个工作流的作用
|
|
280
|
+
trigger:
|
|
281
|
+
type: manual
|
|
282
|
+
vars:
|
|
283
|
+
greeting: hello
|
|
284
|
+
nodes:
|
|
285
|
+
- id: say
|
|
286
|
+
type: send-message
|
|
287
|
+
sessionId: internal:demo
|
|
288
|
+
platform: internal
|
|
289
|
+
content: "{{vars.greeting}}, world!"
|
|
290
|
+
`,
|
|
291
|
+
persist: true,
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
async upsertWorkflowYaml(ctx, args) {
|
|
295
|
+
const svc = ctx.getService('workflow');
|
|
296
|
+
if (!svc)
|
|
297
|
+
return { ok: false, error: 'workflow 服务未就绪' };
|
|
298
|
+
const yamlText = String(args.yaml ?? '').trim();
|
|
299
|
+
if (!yamlText)
|
|
300
|
+
return { ok: false, error: 'yaml 不能为空' };
|
|
301
|
+
let raw;
|
|
302
|
+
try {
|
|
303
|
+
raw = parse(yamlText);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
return { ok: false, error: `YAML 解析失败: ${err instanceof Error ? err.message : err}` };
|
|
307
|
+
}
|
|
308
|
+
const def = normalizeDef(raw, `wf-${Date.now()}`);
|
|
309
|
+
if (!def)
|
|
310
|
+
return { ok: false, error: '定义不合法:缺少 trigger 或 nodes' };
|
|
311
|
+
try {
|
|
312
|
+
await svc.defineWorkflow(def, { persist: args.persist !== false });
|
|
313
|
+
return { ok: true, id: def.id, nodes: def.nodes.length };
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
function toUri(input, fallback) {
|
|
321
|
+
const s = String(input ?? '').trim();
|
|
322
|
+
if (!s)
|
|
323
|
+
return fallback;
|
|
324
|
+
if (s.includes(':/'))
|
|
325
|
+
return s;
|
|
326
|
+
const cleaned = s.replace(/^\.?\/+/, '');
|
|
327
|
+
const idx = cleaned.indexOf('/');
|
|
328
|
+
return idx > 0 ? `${cleaned.slice(0, idx)}:/${cleaned.slice(idx + 1)}` : `${cleaned}:/`;
|
|
329
|
+
}
|
|
330
|
+
function resolveConfig(raw) {
|
|
331
|
+
return {
|
|
332
|
+
defsDir: toUri(typeof raw.defsDir === 'string' ? raw.defsDir : '', 'workspace:/workflows'),
|
|
333
|
+
runsFile: toUri(typeof raw.runsFile === 'string' ? raw.runsFile : '', 'data:/workflow-runs.json'),
|
|
334
|
+
maxRuns: typeof raw.maxRuns === 'number' && raw.maxRuns > 0 ? raw.maxRuns : 200,
|
|
335
|
+
enableTools: raw.enableTools !== false,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// ─── apply ───
|
|
339
|
+
export async function apply(ctx, rawConfig) {
|
|
340
|
+
const config = resolveConfig(rawConfig);
|
|
341
|
+
const logger = ctx.logger.child('workflow');
|
|
342
|
+
const storage = createStorageGateway(ctx);
|
|
343
|
+
const loader = new WorkflowLoader(storage, config.defsDir, logger);
|
|
344
|
+
await loader.loadAll();
|
|
345
|
+
const runStore = new RunStore(storage, config.runsFile, config.maxRuns, logger);
|
|
346
|
+
await runStore.init();
|
|
347
|
+
const cancelTokens = new Map();
|
|
348
|
+
// ── 触发管理器:内部 cron/interval/event 监听 ──
|
|
349
|
+
const triggers = new TriggerManager(ctx, logger, (workflowId, source) => {
|
|
350
|
+
// 异步触发,不阻塞触发源
|
|
351
|
+
runById(workflowId, {}, source).catch(err => {
|
|
352
|
+
logger.error(`触发执行 workflow=${workflowId} source=${source} 失败: ${err instanceof Error ? err.message : err}`);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
for (const def of loader.list())
|
|
356
|
+
triggers.register(def);
|
|
357
|
+
// ── 订阅外部 trigger:fired(来自 scheduler / 其他触发源)──
|
|
358
|
+
ctx.on('trigger:fired', info => {
|
|
359
|
+
if (!info?.workflowId)
|
|
360
|
+
return;
|
|
361
|
+
runById(info.workflowId, info.payload ?? {}, info.source ?? `trigger:${info.type}`).catch(err => {
|
|
362
|
+
logger.error(`trigger:fired workflow=${info.workflowId} 执行失败: ${err instanceof Error ? err.message : err}`);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
// ── 核心:跑一次 workflow ──
|
|
366
|
+
async function runById(workflowId, extraVars, triggerSource) {
|
|
367
|
+
const def = loader.get(workflowId);
|
|
368
|
+
if (!def)
|
|
369
|
+
throw new Error(`workflow "${workflowId}" 不存在`);
|
|
370
|
+
if (def.enabled === false)
|
|
371
|
+
throw new Error(`workflow "${workflowId}" 已禁用`);
|
|
372
|
+
const runId = `${workflowId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
373
|
+
const vars = { ...(def.vars ?? {}), ...extraVars };
|
|
374
|
+
const cancelToken = { cancelled: false };
|
|
375
|
+
cancelTokens.set(runId, cancelToken);
|
|
376
|
+
const run = {
|
|
377
|
+
runId,
|
|
378
|
+
workflowId,
|
|
379
|
+
status: 'running',
|
|
380
|
+
triggerSource,
|
|
381
|
+
startedAt: Date.now(),
|
|
382
|
+
vars,
|
|
383
|
+
nodes: def.nodes.map(n => ({ id: n.id, type: n.type, status: 'pending' })),
|
|
384
|
+
};
|
|
385
|
+
runStore.add(run);
|
|
386
|
+
await ctx.emit('workflow:run:start', run);
|
|
387
|
+
logger.info(`run=${runId} 启动 (trigger=${triggerSource})`);
|
|
388
|
+
try {
|
|
389
|
+
const result = await runDag({
|
|
390
|
+
ctx,
|
|
391
|
+
logger,
|
|
392
|
+
def,
|
|
393
|
+
runId,
|
|
394
|
+
triggerSource,
|
|
395
|
+
vars,
|
|
396
|
+
toolCallContext: {
|
|
397
|
+
sessionId: `workflow::${workflowId}`,
|
|
398
|
+
platform: 'workflow',
|
|
399
|
+
},
|
|
400
|
+
cancelToken,
|
|
401
|
+
onNodeDone: (info) => {
|
|
402
|
+
const idx = run.nodes.findIndex(n => n.id === info.id);
|
|
403
|
+
if (idx >= 0)
|
|
404
|
+
run.nodes[idx] = info;
|
|
405
|
+
ctx.emit('workflow:node:done', { runId, node: info }).catch(() => { });
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
run.status = result.status;
|
|
409
|
+
run.nodes = result.nodes;
|
|
410
|
+
run.error = result.error;
|
|
411
|
+
run.finishedAt = Date.now();
|
|
412
|
+
runStore.update(run);
|
|
413
|
+
if (result.status === 'success') {
|
|
414
|
+
await ctx.emit('workflow:run:done', run);
|
|
415
|
+
logger.info(`run=${runId} 完成 (${run.finishedAt - run.startedAt}ms)`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
await ctx.emit('workflow:run:error', run);
|
|
419
|
+
logger.warn(`run=${runId} ${result.status}: ${result.error ?? ''}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
run.status = 'failed';
|
|
424
|
+
run.error = err instanceof Error ? err.message : String(err);
|
|
425
|
+
run.finishedAt = Date.now();
|
|
426
|
+
runStore.update(run);
|
|
427
|
+
await ctx.emit('workflow:run:error', run);
|
|
428
|
+
logger.error(`run=${runId} 异常: ${run.error}`);
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
cancelTokens.delete(runId);
|
|
432
|
+
}
|
|
433
|
+
return run;
|
|
434
|
+
}
|
|
435
|
+
// ── WorkflowService ──
|
|
436
|
+
const service = {
|
|
437
|
+
listWorkflows() {
|
|
438
|
+
return loader.list();
|
|
439
|
+
},
|
|
440
|
+
getWorkflow(id) {
|
|
441
|
+
return loader.get(id);
|
|
442
|
+
},
|
|
443
|
+
async defineWorkflow(def, opts) {
|
|
444
|
+
const err = validateGraph(def);
|
|
445
|
+
if (err)
|
|
446
|
+
throw new Error(err);
|
|
447
|
+
// 重新触发器
|
|
448
|
+
if (opts?.persist !== false) {
|
|
449
|
+
await loader.saveDef(def);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
loader.putMemory(def);
|
|
453
|
+
}
|
|
454
|
+
triggers.register(def);
|
|
455
|
+
logger.info(`workflow 已定义: ${def.id} (持久化=${opts?.persist !== false})`);
|
|
456
|
+
},
|
|
457
|
+
async removeWorkflow(id) {
|
|
458
|
+
triggers.unregister(id);
|
|
459
|
+
return await loader.removeDef(id);
|
|
460
|
+
},
|
|
461
|
+
async runWorkflow(id, vars, source) {
|
|
462
|
+
return await runById(id, vars ?? {}, source ?? 'manual');
|
|
463
|
+
},
|
|
464
|
+
cancelRun(runId) {
|
|
465
|
+
const t = cancelTokens.get(runId);
|
|
466
|
+
if (!t)
|
|
467
|
+
return false;
|
|
468
|
+
t.cancelled = true;
|
|
469
|
+
return true;
|
|
470
|
+
},
|
|
471
|
+
getRun(runId) {
|
|
472
|
+
return runStore.get(runId);
|
|
473
|
+
},
|
|
474
|
+
listRuns(limit, workflowId) {
|
|
475
|
+
return runStore.list(limit, workflowId);
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
ctx.provide('workflow', service);
|
|
479
|
+
// ── AI 工具 ──
|
|
480
|
+
if (config.enableTools && ctx.getService('tools')) {
|
|
481
|
+
registerTools(ctx, service);
|
|
482
|
+
}
|
|
483
|
+
// ── WebUI ──
|
|
484
|
+
const webui = useWebuiService(ctx);
|
|
485
|
+
for (const page of webuiPages)
|
|
486
|
+
webui.registerPage(page);
|
|
487
|
+
// ── 清理 ──
|
|
488
|
+
ctx.onDispose(() => {
|
|
489
|
+
triggers.dispose();
|
|
490
|
+
cancelTokens.clear();
|
|
491
|
+
});
|
|
492
|
+
logger.info(`工作流插件已启动 (${loader.list().length} 个定义已加载)`);
|
|
493
|
+
}
|
|
494
|
+
// ─── AI 工具注册 ───
|
|
495
|
+
function registerTools(ctx, service) {
|
|
496
|
+
const tools = useToolService(ctx);
|
|
497
|
+
tools.registerGroup({ name: 'workflow', label: '工作流', description: '定义、运行、查询自主工作流(DAG)' });
|
|
498
|
+
tools.register({
|
|
499
|
+
groups: ['workflow'],
|
|
500
|
+
definition: {
|
|
501
|
+
type: 'function',
|
|
502
|
+
function: {
|
|
503
|
+
name: 'workflow_define',
|
|
504
|
+
description: '定义或覆盖一个工作流。yaml 字段为完整的 WorkflowDef YAML 字符串,包含 id/trigger/nodes 等。' +
|
|
505
|
+
' 节点支持类型 tool / send-message / wait;deps 形成 DAG;字符串值支持 {{vars.X}} 与 {{outputs.Y}} 插值。',
|
|
506
|
+
parameters: {
|
|
507
|
+
type: 'object',
|
|
508
|
+
properties: {
|
|
509
|
+
yaml: { type: 'string', description: '完整的 workflow YAML 文本' },
|
|
510
|
+
persist: { type: 'boolean', description: '是否写入磁盘(默认 true)' },
|
|
511
|
+
},
|
|
512
|
+
required: ['yaml'],
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
handler: async (args) => {
|
|
517
|
+
const yamlText = String(args.yaml ?? '');
|
|
518
|
+
let raw;
|
|
519
|
+
try {
|
|
520
|
+
raw = parse(yamlText);
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
return JSON.stringify({ error: `YAML 解析失败: ${e instanceof Error ? e.message : e}` });
|
|
524
|
+
}
|
|
525
|
+
const def = normalizeDef(raw, `wf-${Date.now()}`);
|
|
526
|
+
if (!def)
|
|
527
|
+
return JSON.stringify({ error: '定义不合法:缺少 trigger 或 nodes' });
|
|
528
|
+
try {
|
|
529
|
+
await service.defineWorkflow(def, { persist: args.persist !== false });
|
|
530
|
+
}
|
|
531
|
+
catch (e) {
|
|
532
|
+
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
533
|
+
}
|
|
534
|
+
return JSON.stringify({ ok: true, id: def.id, nodes: def.nodes.length, trigger: def.trigger.type });
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
tools.register({
|
|
538
|
+
groups: ['workflow'],
|
|
539
|
+
definition: {
|
|
540
|
+
type: 'function',
|
|
541
|
+
function: {
|
|
542
|
+
name: 'workflow_list',
|
|
543
|
+
description: '列出全部工作流定义。',
|
|
544
|
+
parameters: { type: 'object', properties: {} },
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
handler: async () => {
|
|
548
|
+
const list = service.listWorkflows().map(d => ({
|
|
549
|
+
id: d.id,
|
|
550
|
+
name: d.name,
|
|
551
|
+
description: d.description,
|
|
552
|
+
trigger: d.trigger,
|
|
553
|
+
nodeCount: d.nodes.length,
|
|
554
|
+
enabled: d.enabled !== false,
|
|
555
|
+
}));
|
|
556
|
+
return JSON.stringify({ total: list.length, workflows: list });
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
tools.register({
|
|
560
|
+
groups: ['workflow'],
|
|
561
|
+
definition: {
|
|
562
|
+
type: 'function',
|
|
563
|
+
function: {
|
|
564
|
+
name: 'workflow_run',
|
|
565
|
+
description: '手动触发一次工作流执行;vars 与定义中的 vars 浅合并后提供给节点插值。',
|
|
566
|
+
parameters: {
|
|
567
|
+
type: 'object',
|
|
568
|
+
properties: {
|
|
569
|
+
id: { type: 'string', description: 'workflow id' },
|
|
570
|
+
vars: { type: 'object', description: '本次运行的额外变量(可选)' },
|
|
571
|
+
},
|
|
572
|
+
required: ['id'],
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
handler: async (args) => {
|
|
577
|
+
try {
|
|
578
|
+
const run = await service.runWorkflow(String(args.id), args.vars ?? {}, 'manual:tool');
|
|
579
|
+
return JSON.stringify({
|
|
580
|
+
ok: true,
|
|
581
|
+
runId: run.runId,
|
|
582
|
+
status: run.status,
|
|
583
|
+
durationMs: (run.finishedAt ?? Date.now()) - run.startedAt,
|
|
584
|
+
nodes: run.nodes.map(n => ({ id: n.id, status: n.status, error: n.error })),
|
|
585
|
+
error: run.error,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
tools.register({
|
|
594
|
+
groups: ['workflow'],
|
|
595
|
+
definition: {
|
|
596
|
+
type: 'function',
|
|
597
|
+
function: {
|
|
598
|
+
name: 'workflow_get_runs',
|
|
599
|
+
description: '查询最近的工作流运行历史(按时间倒序)。',
|
|
600
|
+
parameters: {
|
|
601
|
+
type: 'object',
|
|
602
|
+
properties: {
|
|
603
|
+
workflowId: { type: 'string', description: '只看某个 workflow(可选)' },
|
|
604
|
+
limit: { type: 'number', description: '返回条数(默认 20)' },
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
handler: async (args) => {
|
|
610
|
+
const runs = service.listRuns(typeof args.limit === 'number' ? args.limit : 20, typeof args.workflowId === 'string' ? args.workflowId : undefined);
|
|
611
|
+
return JSON.stringify({
|
|
612
|
+
total: runs.length,
|
|
613
|
+
runs: runs.map(r => ({
|
|
614
|
+
runId: r.runId,
|
|
615
|
+
workflowId: r.workflowId,
|
|
616
|
+
status: r.status,
|
|
617
|
+
triggerSource: r.triggerSource,
|
|
618
|
+
startedAt: new Date(r.startedAt).toISOString(),
|
|
619
|
+
durationMs: (r.finishedAt ?? Date.now()) - r.startedAt,
|
|
620
|
+
error: r.error,
|
|
621
|
+
})),
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
tools.register({
|
|
626
|
+
groups: ['workflow'],
|
|
627
|
+
definition: {
|
|
628
|
+
type: 'function',
|
|
629
|
+
function: {
|
|
630
|
+
name: 'workflow_remove',
|
|
631
|
+
description: '删除一个工作流定义(同时停止其触发器;持久化文件也会被删除)。',
|
|
632
|
+
parameters: {
|
|
633
|
+
type: 'object',
|
|
634
|
+
properties: {
|
|
635
|
+
id: { type: 'string', description: 'workflow id' },
|
|
636
|
+
},
|
|
637
|
+
required: ['id'],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
handler: async (args) => {
|
|
642
|
+
const ok = await service.removeWorkflow(String(args.id));
|
|
643
|
+
return JSON.stringify({ ok, message: ok ? '已删除' : '不存在' });
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
//# sourceMappingURL=index.js.map
|