@aalis/plugin-workflow 0.14.1 → 0.16.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 +25 -13
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +130 -82
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +432 -322
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +36 -11
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +97 -27
- package/dist/loader.js.map +1 -1
- package/dist/locales/en.d.ts +96 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +96 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/persistence.d.ts +17 -7
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +73 -26
- package/dist/persistence.js.map +1 -1
- package/dist/texts.d.ts +191 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +103 -0
- package/dist/texts.js.map +1 -0
- package/dist/triggers.d.ts +3 -2
- package/dist/triggers.d.ts.map +1 -1
- package/dist/triggers.js +67 -30
- package/dist/triggers.js.map +1 -1
- package/package.json +18 -12
package/dist/index.js
CHANGED
|
@@ -5,286 +5,304 @@
|
|
|
5
5
|
// 按 DAG 执行节点,结果存 data:/workflow-runs.json,
|
|
6
6
|
// 定义存 workspace:/workflows/*.yaml。
|
|
7
7
|
// ============================================================
|
|
8
|
+
import { authority } from '@aalis/api-authority';
|
|
8
9
|
import { cronEngine } from '@aalis/api-cron-engine';
|
|
10
|
+
import { gateway } from '@aalis/api-gateway';
|
|
9
11
|
import { hooks } from '@aalis/api-hooks';
|
|
10
|
-
import {
|
|
11
|
-
import { tools } from '@aalis/api-tools';
|
|
12
|
-
import { webuiServer } from '@aalis/api-webui';
|
|
12
|
+
import { isStorageUri, storage } from '@aalis/api-storage';
|
|
13
|
+
import { actingIdentity, tools } from '@aalis/api-tools';
|
|
14
|
+
import { markdownCode, webuiServer } from '@aalis/api-webui';
|
|
13
15
|
import { workflow } from '@aalis/api-workflow';
|
|
14
16
|
import { config, definePlugin, events, lifecycle, logger, optional, provide } from '@aalis/core';
|
|
15
|
-
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
17
|
+
import { configError, defineConfig, parseConfig } from '@aalis/schema-config';
|
|
18
|
+
import { TextError, textClauses, textOf, toSource } from '@aalis/schema-i18n';
|
|
19
|
+
import { validateIntervalSeconds } from '@aalis/util-cron';
|
|
16
20
|
import { parse, stringify } from 'yaml';
|
|
17
|
-
import { runDag, validateGraph } from './engine.js';
|
|
18
|
-
import { normalizeDef, WorkflowLoader } from './loader.js';
|
|
21
|
+
import { errorReason, runDag, validateGraph } from './engine.js';
|
|
22
|
+
import { normalizeDef, validateId, WorkflowLoader } from './loader.js';
|
|
19
23
|
import { RunStore } from './persistence.js';
|
|
20
|
-
import {
|
|
24
|
+
import { t } from './texts.js';
|
|
25
|
+
import { BLOCKED_EVENT_TRIGGERS, TriggerManager } from './triggers.js';
|
|
21
26
|
// ─── 配置 ───
|
|
22
27
|
const configSchema = defineConfig({
|
|
23
28
|
defsDir: {
|
|
24
29
|
type: 'string',
|
|
25
|
-
label: '
|
|
30
|
+
label: t('defsDirLabel'),
|
|
26
31
|
default: 'workspace:/workflows',
|
|
27
32
|
onInvalid: 'error',
|
|
28
|
-
description: '
|
|
33
|
+
description: t('defsDirDescription'),
|
|
29
34
|
},
|
|
30
35
|
runsFile: {
|
|
31
36
|
type: 'string',
|
|
32
|
-
label: '
|
|
37
|
+
label: t('runsFileLabel'),
|
|
33
38
|
default: 'data:/workflow-runs.json',
|
|
34
39
|
onInvalid: 'error',
|
|
35
|
-
description: '
|
|
40
|
+
description: t('runsFileDescription'),
|
|
36
41
|
},
|
|
37
42
|
maxRuns: {
|
|
38
43
|
type: 'number',
|
|
39
|
-
label: '
|
|
44
|
+
label: t('maxRunsLabel'),
|
|
40
45
|
default: 200,
|
|
41
|
-
description: '
|
|
46
|
+
description: t('maxRunsDescription'),
|
|
42
47
|
},
|
|
43
48
|
enableTools: {
|
|
44
49
|
type: 'boolean',
|
|
45
|
-
label: '
|
|
50
|
+
label: t('enableToolsLabel'),
|
|
46
51
|
default: true,
|
|
47
52
|
onInvalid: 'error',
|
|
48
|
-
description: '
|
|
53
|
+
description: t('enableToolsDescription'),
|
|
49
54
|
},
|
|
50
55
|
});
|
|
51
|
-
// ───
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
key: 'workflows',
|
|
55
|
-
label: '工作流',
|
|
56
|
-
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>',
|
|
57
|
-
order: 57,
|
|
58
|
-
content: [
|
|
59
|
-
{
|
|
60
|
-
type: 'stat',
|
|
61
|
-
label: '工作流总览',
|
|
62
|
-
source: 'workflowStats',
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
type: 'table',
|
|
66
|
-
label: '工作流定义',
|
|
67
|
-
source: 'listWorkflowsTable',
|
|
68
|
-
columns: [
|
|
69
|
-
{ key: 'id', label: 'ID', nowrap: true },
|
|
70
|
-
{ key: 'name', label: '名称' },
|
|
71
|
-
{ key: 'trigger', label: '触发器', nowrap: true },
|
|
72
|
-
{ key: 'nodeCount', label: '节点' },
|
|
73
|
-
{ key: 'enabled', label: '状态', nowrap: true },
|
|
74
|
-
{ key: 'description', label: '描述', maxWidth: 360, render: 'expandable-text' },
|
|
75
|
-
],
|
|
76
|
-
actions: [
|
|
77
|
-
{ label: '立即运行', method: 'triggerWorkflow' },
|
|
78
|
-
{ label: '禁用/启用', method: 'toggleWorkflow' },
|
|
79
|
-
{ label: '查看 YAML', method: 'getWorkflowYaml' },
|
|
80
|
-
{ label: '删除', method: 'removeWorkflow', confirm: '确定删除该工作流?(同时删除磁盘文件)', danger: true },
|
|
81
|
-
],
|
|
82
|
-
refresh: 60,
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
type: 'table',
|
|
86
|
-
label: '最近运行历史',
|
|
87
|
-
source: 'listRunsTable',
|
|
88
|
-
columns: [
|
|
89
|
-
{ key: 'runId', label: 'Run ID', nowrap: true },
|
|
90
|
-
{ key: 'workflowId', label: '工作流', nowrap: true },
|
|
91
|
-
{ key: 'status', label: '状态', nowrap: true },
|
|
92
|
-
{ key: 'triggerSource', label: '触发源', nowrap: true },
|
|
93
|
-
{ key: 'startedAtText', label: '开始时间', nowrap: true },
|
|
94
|
-
{ key: 'durationText', label: '耗时', nowrap: true },
|
|
95
|
-
{ key: 'error', label: '错误', maxWidth: 320, render: 'expandable-text' },
|
|
96
|
-
],
|
|
97
|
-
refresh: 30,
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
type: 'form',
|
|
101
|
-
label: '新建 / 覆盖工作流(粘贴完整 YAML)',
|
|
102
|
-
source: 'newWorkflowDraft',
|
|
103
|
-
save: 'upsertWorkflowYaml',
|
|
104
|
-
schema: {
|
|
105
|
-
yaml: {
|
|
106
|
-
type: 'textarea',
|
|
107
|
-
label: 'YAML 定义',
|
|
108
|
-
required: true,
|
|
109
|
-
description: '完整 WorkflowDef YAML:id / name / trigger / nodes。trigger 类型支持 cron / interval / once / event / manual。' +
|
|
110
|
-
'节点类型 tool / send-message / wait / agent(agent 节点派发指令给 agent 并等回复,配合 deps + {{outputs.X}} 做多智能体编排)。' +
|
|
111
|
-
'保存后将立即注册触发器并持久化到 defsDir。',
|
|
112
|
-
},
|
|
113
|
-
persist: {
|
|
114
|
-
type: 'boolean',
|
|
115
|
-
label: '写入磁盘',
|
|
116
|
-
default: true,
|
|
117
|
-
description: '关闭后仅在内存中注册(进程重启后丢失)',
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
],
|
|
122
|
-
},
|
|
123
|
-
];
|
|
124
|
-
// ─── 工具函数(与页面动作共享) ───
|
|
125
|
-
function triggerSummary(t) {
|
|
126
|
-
switch (t.type) {
|
|
56
|
+
// ─── 工具函数(与工作流页共享) ───
|
|
57
|
+
function triggerSummary(trigger) {
|
|
58
|
+
switch (trigger.type) {
|
|
127
59
|
case 'cron':
|
|
128
|
-
return `cron ${
|
|
60
|
+
return `cron ${trigger.expr ?? ''}`.trim();
|
|
129
61
|
case 'interval':
|
|
130
|
-
return
|
|
62
|
+
return t('everySeconds', { seconds: trigger.seconds ?? '?' });
|
|
131
63
|
case 'once':
|
|
132
|
-
return
|
|
64
|
+
return t('onceAt', { at: trigger.runAt ?? '?' });
|
|
133
65
|
case 'event':
|
|
134
|
-
return `event:${
|
|
66
|
+
return `event:${trigger.event ?? '?'}`;
|
|
135
67
|
case 'manual':
|
|
136
|
-
return '
|
|
68
|
+
return t('manual');
|
|
137
69
|
default:
|
|
138
|
-
return
|
|
70
|
+
return trigger.type;
|
|
139
71
|
}
|
|
140
72
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return String(ts);
|
|
149
|
-
}
|
|
73
|
+
/** 有平台与用户 ID 的才是一个身份,缺一个即匿名 */
|
|
74
|
+
function identityOf(who) {
|
|
75
|
+
return who?.platform && who.userId ? { platform: who.platform, userId: who.userId } : undefined;
|
|
150
76
|
}
|
|
77
|
+
/** 定义表里的定义者:平台:用户 ID,没有的写「匿名」 */
|
|
78
|
+
const identityText = (who) => (who ? `${who.platform}:${who.userId}` : t('anonymous'));
|
|
79
|
+
/** 用时的写法:字典的 {ms, duration}(到秒,按读者的语言);没有用时写「—」 */
|
|
151
80
|
function fmtDuration(ms) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const m = Math.floor(ms / 60_000);
|
|
159
|
-
const s = Math.floor((ms % 60_000) / 1000);
|
|
160
|
-
return `${m}m${s}s`;
|
|
81
|
+
return ms === undefined || !Number.isFinite(ms) || ms < 0 ? '—' : t('elapsed', { ms });
|
|
82
|
+
}
|
|
83
|
+
/** 运行的用时(ms):在跑的算到现在;进程退出时中断的不知道停在何时,没有用时 */
|
|
84
|
+
function elapsed(run) {
|
|
85
|
+
const end = run.status === 'running' ? Date.now() : run.finishedAt;
|
|
86
|
+
return end === undefined ? undefined : end - run.startedAt;
|
|
161
87
|
}
|
|
162
88
|
/**
|
|
163
|
-
* once 记账读不出时本次运行不安排 once:定义或启用一个 once 的回执据此如实说明(AI
|
|
89
|
+
* once 记账读不出时本次运行不安排 once:定义或启用一个 once 的回执据此如实说明(AI 工具与工作流页同一口径,工具取原文)。
|
|
164
90
|
* manualRun 是各自那一面手动执行的说法;不持久化的定义重启后就没了,「重启恢复」只对写了文件的定义成立。
|
|
165
91
|
*/
|
|
166
92
|
function onceSkippedNote(def, persist, onceLedgerReadable, manualRun) {
|
|
167
93
|
if (def.trigger.type !== 'once' || def.enabled === false || onceLedgerReadable)
|
|
168
94
|
return undefined;
|
|
169
|
-
return
|
|
95
|
+
return t(persist ? 'onceSkippedPersisted' : 'onceSkipped', { manualRun });
|
|
170
96
|
}
|
|
97
|
+
/** 工作流页的回执:once 记账读不出时接上附注 */
|
|
98
|
+
const withNote = (receipt, note) => (note ? textClauses([receipt, note]) : receipt);
|
|
99
|
+
const STATUS = {
|
|
100
|
+
pending: { text: t('statusPending'), state: 'inactive' },
|
|
101
|
+
running: { text: t('statusRunning'), state: 'running' },
|
|
102
|
+
success: { text: t('statusSuccess') },
|
|
103
|
+
failed: { text: t('statusFailed'), state: 'error' },
|
|
104
|
+
cancelled: { text: t('statusCancelled'), state: 'inactive' },
|
|
105
|
+
};
|
|
171
106
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
107
|
+
* 工作流页:处理函数是闭包,直接用本次激活构造的 WorkflowService,不按名回查容器(查到的可能是别人提供的同名服务)。
|
|
108
|
+
* 返回页面的句柄:定义增删与运行起止时让它重取
|
|
174
109
|
*/
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const enabled = defs.filter(d => d.enabled !== false).length;
|
|
179
|
-
const runs = service.listRuns(200);
|
|
180
|
-
const last24h = runs.filter(r => Date.now() - r.startedAt < 86_400_000);
|
|
181
|
-
const failed24h = last24h.filter(r => r.status === 'failed' || r.status === 'cancelled').length;
|
|
182
|
-
return {
|
|
183
|
-
value: defs.length,
|
|
184
|
-
hint: `启用 ${enabled} · 24h 运行 ${last24h.length} · 失败 ${failed24h}`,
|
|
185
|
-
};
|
|
186
|
-
});
|
|
187
|
-
webui.registerAction('listWorkflowsTable', async () => {
|
|
188
|
-
return service.listWorkflows().map(d => ({
|
|
189
|
-
id: d.id,
|
|
190
|
-
name: d.name ?? d.id,
|
|
191
|
-
trigger: triggerSummary(d.trigger),
|
|
192
|
-
nodeCount: d.nodes.length,
|
|
193
|
-
enabled: d.enabled === false ? '⏸ 禁用' : '✅ 启用',
|
|
194
|
-
description: d.description ?? '',
|
|
195
|
-
}));
|
|
196
|
-
});
|
|
197
|
-
webui.registerAction('listRunsTable', async () => {
|
|
198
|
-
return service.listRuns(50).map(r => {
|
|
199
|
-
const dur = (r.finishedAt ?? Date.now()) - r.startedAt;
|
|
200
|
-
const statusIcon = r.status === 'success' ? '✅' : r.status === 'failed' ? '❌' : r.status === 'cancelled' ? '⏹' : '⏳';
|
|
201
|
-
return {
|
|
202
|
-
runId: r.runId,
|
|
203
|
-
workflowId: r.workflowId,
|
|
204
|
-
status: `${statusIcon} ${r.status}`,
|
|
205
|
-
triggerSource: r.triggerSource,
|
|
206
|
-
startedAtText: fmtTs(r.startedAt),
|
|
207
|
-
durationText: fmtDuration(dur),
|
|
208
|
-
error: r.error ?? '',
|
|
209
|
-
};
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
webui.registerAction('triggerWorkflow', async (args) => {
|
|
213
|
-
try {
|
|
214
|
-
const run = await service.runWorkflow(String(args.id), {}, 'manual:webui');
|
|
215
|
-
return {
|
|
216
|
-
ok: true,
|
|
217
|
-
runId: run.runId,
|
|
218
|
-
status: run.status,
|
|
219
|
-
durationMs: (run.finishedAt ?? Date.now()) - run.startedAt,
|
|
220
|
-
error: run.error,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
catch (err) {
|
|
224
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
webui.registerAction('toggleWorkflow', async (args) => {
|
|
228
|
-
const def = service.getWorkflow(String(args.id));
|
|
110
|
+
function defineWorkflowPage(webui, service, definitions, onceLedgerReadable) {
|
|
111
|
+
const definitionOf = (id) => {
|
|
112
|
+
const def = service.getWorkflow(id);
|
|
229
113
|
if (!def)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
114
|
+
throw new TextError(t('workflowMissing', { id }));
|
|
115
|
+
return def;
|
|
116
|
+
};
|
|
117
|
+
/** 启用或停用:改的是写在磁盘上的定义,定义者沿用原来的 */
|
|
118
|
+
const setEnabled = async (id, enabled) => {
|
|
119
|
+
const next = { ...definitionOf(id), enabled };
|
|
120
|
+
await definitions.define(next, true, definitions.definedBy(id));
|
|
121
|
+
const note = onceSkippedNote(next, true, onceLedgerReadable(), t('runNow'));
|
|
122
|
+
return withNote(t(enabled ? 'enabled' : 'disabled', { id }), note);
|
|
123
|
+
};
|
|
124
|
+
return webui.definePage({
|
|
125
|
+
key: 'workflows',
|
|
126
|
+
title: t('displayName'),
|
|
127
|
+
icon: 'workflow',
|
|
128
|
+
actions: {
|
|
129
|
+
runWorkflow: {
|
|
130
|
+
label: t('runWorkflow'),
|
|
131
|
+
effect: 'write',
|
|
132
|
+
row: true,
|
|
133
|
+
async handler({ rowKey, ctx }) {
|
|
134
|
+
// 调用者是登录的 owner:按定义者执行(见 execute 的运行身份)
|
|
135
|
+
const run = await service.runWorkflow(rowKey, {}, 'manual:webui', ctx.caller);
|
|
136
|
+
if (run.status !== 'success') {
|
|
137
|
+
const status = STATUS[run.status].text;
|
|
138
|
+
throw new TextError(run.error ? t('runNotSucceededBecause', { status, error: run.error }) : t('runNotSucceeded', { status }));
|
|
139
|
+
}
|
|
140
|
+
return t('ran', { id: rowKey, duration: fmtDuration(elapsed(run)) });
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
enableWorkflow: {
|
|
144
|
+
label: t('enableAction'),
|
|
145
|
+
effect: 'write',
|
|
146
|
+
row: true,
|
|
147
|
+
handler: ({ rowKey }) => setEnabled(rowKey, true),
|
|
148
|
+
},
|
|
149
|
+
disableWorkflow: {
|
|
150
|
+
label: t('disableAction'),
|
|
151
|
+
effect: 'write',
|
|
152
|
+
row: true,
|
|
153
|
+
handler: ({ rowKey }) => setEnabled(rowKey, false),
|
|
154
|
+
},
|
|
155
|
+
removeWorkflow: {
|
|
156
|
+
label: t('delete'),
|
|
157
|
+
effect: 'destructive',
|
|
158
|
+
confirm: t('deleteConfirm'),
|
|
159
|
+
row: true,
|
|
160
|
+
async handler({ rowKey }) {
|
|
161
|
+
if (!(await service.removeWorkflow(rowKey)))
|
|
162
|
+
throw new TextError(t('workflowMissing', { id: rowKey }));
|
|
163
|
+
return t('deleted', { id: rowKey });
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
cancelRun: {
|
|
167
|
+
label: t('cancelRun'),
|
|
168
|
+
effect: 'destructive',
|
|
169
|
+
confirm: t('cancelRunConfirm'),
|
|
170
|
+
row: true,
|
|
171
|
+
// 只发出取消:运行照常收尾,记录留在运行历史里、状态为已取消,收尾时页面重取
|
|
172
|
+
handler({ rowKey }) {
|
|
173
|
+
if (!service.cancelRun(rowKey))
|
|
174
|
+
throw new TextError(t('runEnded'));
|
|
175
|
+
return t('runCancelled', { id: rowKey });
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
defineWorkflow: {
|
|
179
|
+
label: t('defineWorkflow'),
|
|
180
|
+
effect: 'write',
|
|
181
|
+
input: {
|
|
182
|
+
yaml: {
|
|
183
|
+
type: 'textarea',
|
|
184
|
+
label: t('yamlLabel'),
|
|
185
|
+
required: true,
|
|
186
|
+
description: t('yamlDescription'),
|
|
187
|
+
},
|
|
188
|
+
persist: {
|
|
189
|
+
type: 'boolean',
|
|
190
|
+
label: t('persistLabel'),
|
|
191
|
+
default: true,
|
|
192
|
+
description: t('persistDescription'),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
async handler({ input, ctx }) {
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = parse(input.yaml);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
throw new TextError(t('yamlFailed', { error: textOf(err) }));
|
|
202
|
+
}
|
|
203
|
+
const def = normalizeDef(raw, `wf-${Date.now()}`);
|
|
204
|
+
if (!def)
|
|
205
|
+
throw new TextError(t('badDefinition'));
|
|
206
|
+
await definitions.define(def, input.persist, ctx.caller);
|
|
207
|
+
const note = onceSkippedNote(def, input.persist, onceLedgerReadable(), t('runNow'));
|
|
208
|
+
return withNote(t('saved', { id: def.id }), note);
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: 'info',
|
|
215
|
+
id: 'summary',
|
|
216
|
+
data() {
|
|
217
|
+
const defs = service.listWorkflows();
|
|
218
|
+
const recent = service.listRuns(200).filter(r => Date.now() - r.startedAt < 86_400_000);
|
|
219
|
+
return [
|
|
220
|
+
{ label: t('definitions'), value: defs.length },
|
|
221
|
+
{ label: t('stateEnabled'), value: defs.filter(d => d.enabled !== false).length },
|
|
222
|
+
{ label: t('runs24h'), value: recent.length },
|
|
223
|
+
{
|
|
224
|
+
label: t('failures24h'),
|
|
225
|
+
value: recent.filter(r => r.status === 'failed').length,
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
type: 'table',
|
|
232
|
+
id: 'workflows',
|
|
233
|
+
label: t('definitions'),
|
|
234
|
+
rowKey: 'id',
|
|
235
|
+
empty: t('noWorkflows'),
|
|
236
|
+
columns: [
|
|
237
|
+
{ key: 'id', label: 'ID', kind: 'code' },
|
|
238
|
+
{ key: 'name', label: t('name'), primary: true },
|
|
239
|
+
{ key: 'trigger', label: t('trigger') },
|
|
240
|
+
{ key: 'nodes', label: t('nodeCount') },
|
|
241
|
+
{ key: 'status', label: t('status'), kind: 'status', primary: true },
|
|
242
|
+
{ key: 'definedBy', label: t('definedBy') },
|
|
243
|
+
{ key: 'description', label: t('description'), kind: 'long-text' },
|
|
244
|
+
],
|
|
245
|
+
rowActions: [
|
|
246
|
+
{ action: 'runWorkflow' },
|
|
247
|
+
{ action: 'enableWorkflow', visibleWhen: 'disabled' },
|
|
248
|
+
{ action: 'disableWorkflow', visibleWhen: 'enabled' },
|
|
249
|
+
{ action: 'removeWorkflow' },
|
|
250
|
+
],
|
|
251
|
+
data: () => service.listWorkflows().map(d => ({
|
|
252
|
+
id: d.id,
|
|
253
|
+
name: d.name ?? d.id,
|
|
254
|
+
trigger: triggerSummary(d.trigger),
|
|
255
|
+
nodes: d.nodes.length,
|
|
256
|
+
status: d.enabled === false
|
|
257
|
+
? { text: t('stateDisabled'), state: 'inactive' }
|
|
258
|
+
: { text: t('stateEnabled') },
|
|
259
|
+
enabled: d.enabled !== false,
|
|
260
|
+
disabled: d.enabled === false,
|
|
261
|
+
description: d.description ?? '',
|
|
262
|
+
definedBy: identityText(definitions.definedBy(d.id)),
|
|
263
|
+
})),
|
|
264
|
+
detail: [
|
|
265
|
+
{
|
|
266
|
+
type: 'markdown',
|
|
267
|
+
id: 'yaml',
|
|
268
|
+
label: 'YAML',
|
|
269
|
+
data: ({ rowKey }) => markdownCode(stringify(definitionOf(rowKey)), 'yaml'),
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
type: 'table',
|
|
275
|
+
id: 'runs',
|
|
276
|
+
label: t('recentRuns'),
|
|
277
|
+
rowKey: 'runId',
|
|
278
|
+
empty: t('noRuns'),
|
|
279
|
+
columns: [
|
|
280
|
+
{ key: 'workflowId', label: t('workflowColumn'), primary: true },
|
|
281
|
+
{ key: 'status', label: t('status'), kind: 'status', primary: true },
|
|
282
|
+
{ key: 'triggerSource', label: t('triggerSource') },
|
|
283
|
+
{ key: 'startedAt', label: t('startedAt'), kind: 'time' },
|
|
284
|
+
{ key: 'duration', label: t('duration') },
|
|
285
|
+
{ key: 'error', label: t('error'), kind: 'long-text' },
|
|
286
|
+
{ key: 'runId', label: t('runId'), kind: 'code' },
|
|
287
|
+
],
|
|
288
|
+
rowActions: [{ action: 'cancelRun', visibleWhen: 'running' }],
|
|
289
|
+
// 最近 50 次,更早而还在跑的接在后面(按开始时间倒序,它们本就排在后面):取消的入口不随新的运行挤掉
|
|
290
|
+
data: () => service
|
|
291
|
+
.listRuns()
|
|
292
|
+
.filter((r, i) => i < 50 || r.status === 'running')
|
|
293
|
+
.map(r => ({
|
|
294
|
+
runId: r.runId,
|
|
295
|
+
workflowId: r.workflowId,
|
|
296
|
+
status: STATUS[r.status],
|
|
297
|
+
triggerSource: r.triggerSource,
|
|
298
|
+
startedAt: r.startedAt,
|
|
299
|
+
duration: fmtDuration(elapsed(r)),
|
|
300
|
+
error: r.error ?? '',
|
|
301
|
+
running: r.status === 'running',
|
|
302
|
+
})),
|
|
303
|
+
},
|
|
304
|
+
{ type: 'actions', actions: ['defineWorkflow'] },
|
|
305
|
+
],
|
|
288
306
|
});
|
|
289
307
|
}
|
|
290
308
|
/** 路径配置只接受 storage URI;留空用默认值,其它写法(如相对路径 workspace/workflows)拒绝激活。 */
|
|
@@ -293,7 +311,7 @@ function resolveUri(key, input, fallback) {
|
|
|
293
311
|
if (!s)
|
|
294
312
|
return fallback;
|
|
295
313
|
if (!isStorageUri(s)) {
|
|
296
|
-
throw
|
|
314
|
+
throw configError(t('badUri', { key, value: s, fallback }));
|
|
297
315
|
}
|
|
298
316
|
return s;
|
|
299
317
|
}
|
|
@@ -305,11 +323,15 @@ const uses = {
|
|
|
305
323
|
storage: optional(storage),
|
|
306
324
|
/** tool 节点的执行入口,兼 AI 工具面的登记口 */
|
|
307
325
|
tools: optional(tools),
|
|
308
|
-
/**
|
|
326
|
+
/** 工作流页的登记口;无 WebUI 时引擎照常运行 */
|
|
309
327
|
webui: optional(webuiServer),
|
|
328
|
+
/** 判调用者是不是 owner,定运行身份;缺席时判不出谁是 owner,调用者与定义者不是同一身份的按匿名 */
|
|
329
|
+
authority: optional(authority),
|
|
310
330
|
events,
|
|
311
331
|
/** agent 节点经 'agent:turn:after' 捕获本轮回复 */
|
|
312
332
|
hooks,
|
|
333
|
+
/** agent 节点经它送达指令;缺席时 agent 节点失败 */
|
|
334
|
+
gateway: optional(gateway),
|
|
313
335
|
lifecycle,
|
|
314
336
|
logger,
|
|
315
337
|
config,
|
|
@@ -317,7 +339,7 @@ const uses = {
|
|
|
317
339
|
};
|
|
318
340
|
export default definePlugin({
|
|
319
341
|
name: '@aalis/plugin-workflow',
|
|
320
|
-
displayName: '
|
|
342
|
+
displayName: t('displayName'),
|
|
321
343
|
subsystem: 'workflow',
|
|
322
344
|
extends: {
|
|
323
345
|
events: ['workflow:run:start', 'workflow:run:done', 'workflow:run:error', 'workflow:node:done', 'trigger:fired'],
|
|
@@ -329,52 +351,76 @@ export default definePlugin({
|
|
|
329
351
|
});
|
|
330
352
|
async function run(caps) {
|
|
331
353
|
const { events, hooks, lifecycle, provide, tools, webui } = caps;
|
|
332
|
-
const cfg = parseConfig(configSchema, caps.config, caps.logger);
|
|
354
|
+
const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
|
|
333
355
|
const config = {
|
|
334
356
|
...cfg,
|
|
335
357
|
defsDir: resolveUri('defsDir', cfg.defsDir, configSchema.defsDir.default),
|
|
336
358
|
runsFile: resolveUri('runsFile', cfg.runsFile, configSchema.runsFile.default),
|
|
337
359
|
maxRuns: cfg.maxRuns > 0 ? cfg.maxRuns : configSchema.maxRuns.default,
|
|
338
360
|
};
|
|
339
|
-
const logger = caps.logger.child('workflow');
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
const scanned = await loader.loadAll();
|
|
343
|
-
const runStore = new RunStore(storageGateway, config.runsFile, config.maxRuns, logger);
|
|
361
|
+
const logger = caps.logger.use.child('workflow');
|
|
362
|
+
// 先读运行历史:载入定义时按里面的定义者账本认定义者
|
|
363
|
+
const runStore = new RunStore(caps.storage.use, config.runsFile, config.maxRuns, logger);
|
|
344
364
|
await runStore.init();
|
|
345
|
-
const
|
|
365
|
+
const loader = new WorkflowLoader(caps.storage.use, config.defsDir, logger);
|
|
366
|
+
const scanned = await loader.loadAll((id, sha256) => runStore.definerOf(id, sha256));
|
|
367
|
+
/** 在飞 run 的取消开关,按 runId */
|
|
368
|
+
const aborts = new Map();
|
|
369
|
+
/** 在飞的 run:拆卸先中止,再等它们收尾,被取消的 run 也要把终态写进运行历史 */
|
|
370
|
+
const running = new Set();
|
|
371
|
+
/** 定时与事件触发:以定义者作调用者 */
|
|
372
|
+
const runAsDefiner = (workflowId, vars, source) => runById(workflowId, vars, source, loader.definedBy(workflowId));
|
|
346
373
|
// ── 触发管理器:内部 cron/interval/event 监听 ──
|
|
347
|
-
const triggers = new TriggerManager({ cronEngine: caps.cronEngine, events, logger }, (workflowId, source, payload) => {
|
|
374
|
+
const triggers = new TriggerManager({ cronEngine: caps.cronEngine, events: events.use, logger }, (workflowId, source, payload) => {
|
|
348
375
|
// 异步触发,不阻塞触发源;event 触发的 payload({args})作为 vars 注入,{{vars.X}} 才读得到
|
|
349
|
-
|
|
376
|
+
runAsDefiner(workflowId, payload ?? {}, source).catch(err => {
|
|
350
377
|
logger.error(`触发执行 workflow=${workflowId} source=${source} 失败: ${err instanceof Error ? err.message : err}`);
|
|
351
378
|
});
|
|
352
379
|
}, runStore);
|
|
353
|
-
// 定义已从磁盘删除的,once
|
|
380
|
+
// 定义已从磁盘删除的,once 记账与定义者不留孤儿(手删 yaml 不经 removeWorkflow);注册前清。
|
|
354
381
|
// 列目录失败那次启动看到的是空集,不能据此清账——否则下次启动所有过期 once 全部重放
|
|
355
382
|
if (scanned)
|
|
356
|
-
runStore.
|
|
383
|
+
runStore.prune(new Set(loader.list().map(d => d.id)));
|
|
357
384
|
for (const def of loader.list())
|
|
358
385
|
triggers.register(def);
|
|
359
386
|
// ── 订阅外部 trigger:fired(来自 scheduler / 其他触发源)──
|
|
360
|
-
events.on('trigger:fired', info => {
|
|
387
|
+
events.use.on('trigger:fired', info => {
|
|
361
388
|
if (!info?.workflowId)
|
|
362
389
|
return;
|
|
363
|
-
|
|
390
|
+
runAsDefiner(info.workflowId, info.payload ?? {}, info.source ?? `trigger:${info.type}`).catch(err => {
|
|
364
391
|
logger.error(`trigger:fired workflow=${info.workflowId} 执行失败: ${err instanceof Error ? err.message : err}`);
|
|
365
392
|
});
|
|
366
393
|
});
|
|
367
|
-
// ── 核心:跑一次 workflow
|
|
368
|
-
|
|
394
|
+
// ── 核心:跑一次 workflow(登记进 running,拆卸据此等它收尾)──
|
|
395
|
+
function runById(...args) {
|
|
396
|
+
const pending = execute(...args);
|
|
397
|
+
running.add(pending);
|
|
398
|
+
const settle = () => running.delete(pending);
|
|
399
|
+
pending.then(settle, settle);
|
|
400
|
+
return pending;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* 运行身份:调用者是 owner 时取定义者(没有即匿名),否则取调用者。两者都是 owner 才按 owner 跑,有一方不是 owner 就由
|
|
404
|
+
* 那一方定:owner 调起别人定义或覆盖过的编排按那个人的等级执行,免确认也只对 owner 生效,借别人的编排提不了权
|
|
405
|
+
*/
|
|
406
|
+
function runIdentity(invoker, definer) {
|
|
407
|
+
if (caps.authority.present) {
|
|
408
|
+
return invoker && caps.authority.use.isOwner(invoker.platform, invoker.userId) ? definer : invoker;
|
|
409
|
+
}
|
|
410
|
+
// 权限服务不在(停用或重载的窗口)时判不出谁是 owner:两者是同一身份才按它,否则匿名
|
|
411
|
+
return invoker?.platform === definer?.platform && invoker?.userId === definer?.userId ? invoker : undefined;
|
|
412
|
+
}
|
|
413
|
+
async function execute(workflowId, extraVars, triggerSource, caller) {
|
|
369
414
|
const def = loader.get(workflowId);
|
|
370
415
|
if (!def)
|
|
371
|
-
throw new
|
|
416
|
+
throw new TextError(t('runMissing', { id: workflowId }));
|
|
372
417
|
if (def.enabled === false)
|
|
373
|
-
throw new
|
|
418
|
+
throw new TextError(t('runDisabled', { id: workflowId }));
|
|
419
|
+
const actor = runIdentity(identityOf(caller), loader.definedBy(workflowId));
|
|
374
420
|
const runId = `${workflowId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
375
421
|
const vars = { ...(def.vars ?? {}), ...extraVars };
|
|
376
|
-
const
|
|
377
|
-
|
|
422
|
+
const abort = new AbortController();
|
|
423
|
+
aborts.set(runId, abort);
|
|
378
424
|
const run = {
|
|
379
425
|
runId,
|
|
380
426
|
workflowId,
|
|
@@ -385,62 +431,106 @@ async function run(caps) {
|
|
|
385
431
|
nodes: def.nodes.map(n => ({ id: n.id, type: n.type, status: 'pending' })),
|
|
386
432
|
};
|
|
387
433
|
runStore.add(run);
|
|
388
|
-
|
|
434
|
+
page.invalidate();
|
|
435
|
+
await events.use.emit('workflow:run:start', run);
|
|
389
436
|
logger.info(`run=${runId} 启动 (trigger=${triggerSource})`);
|
|
390
437
|
try {
|
|
438
|
+
// 跑完就摘掉中止句柄,再记终态、发收尾事件:之后的 cancelRun 回 false,不对已经落定的运行回「已取消」
|
|
391
439
|
const result = await runDag({
|
|
392
440
|
tools,
|
|
393
|
-
events,
|
|
394
|
-
hooks,
|
|
441
|
+
events: events.use,
|
|
442
|
+
hooks: hooks.use,
|
|
443
|
+
gateway: caps.gateway,
|
|
395
444
|
logger,
|
|
396
445
|
def,
|
|
397
446
|
runId,
|
|
398
447
|
vars,
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// 'workflow' 会话语义(不再借调用者平台——那会污染定时任务归属/confirm 选路等分流)。
|
|
403
|
-
// cron/event/webui 触发无调用者 → 保持匿名(仅能跑 public 工具)。
|
|
448
|
+
// 运行身份是工具调用上下文的唯一参与者,交给工作流内部工具与 agent/send_message 节点,按它的等级过 authority 闸;
|
|
449
|
+
// 匿名的是无主体,只能跑 public 工具。platform 保持 'workflow' 会话语义(不借调用者平台——那会污染定时任务归属/审批的
|
|
450
|
+
// 发起类别等分流)。
|
|
404
451
|
toolCallContext: {
|
|
405
452
|
sessionId: `workflow::${workflowId}`,
|
|
406
453
|
platform: 'workflow',
|
|
407
|
-
|
|
454
|
+
participants: [actor ?? { platform: 'workflow', userId: '' }],
|
|
408
455
|
},
|
|
409
|
-
|
|
456
|
+
callerSessionId: caller?.sessionId,
|
|
457
|
+
signal: abort.signal,
|
|
410
458
|
onNodeDone: (info) => {
|
|
411
459
|
const idx = run.nodes.findIndex(n => n.id === info.id);
|
|
412
460
|
if (idx >= 0)
|
|
413
461
|
run.nodes[idx] = info;
|
|
414
|
-
events.emit('workflow:node:done', { runId, node: info }).catch(() => { });
|
|
462
|
+
events.use.emit('workflow:node:done', { runId, node: info }).catch(() => { });
|
|
415
463
|
},
|
|
416
|
-
});
|
|
464
|
+
}).finally(() => aborts.delete(runId));
|
|
417
465
|
run.status = result.status;
|
|
418
466
|
run.nodes = result.nodes;
|
|
419
467
|
run.error = result.error;
|
|
420
468
|
run.finishedAt = Date.now();
|
|
421
469
|
runStore.update(run);
|
|
422
470
|
if (result.status === 'success') {
|
|
423
|
-
await events.emit('workflow:run:done', run);
|
|
471
|
+
await events.use.emit('workflow:run:done', run);
|
|
424
472
|
logger.info(`run=${runId} 完成 (${run.finishedAt - run.startedAt}ms)`);
|
|
425
473
|
}
|
|
426
474
|
else {
|
|
427
|
-
await events.emit('workflow:run:error', run);
|
|
428
|
-
logger.warn(`run=${runId} ${result.status}: ${result.error ?? ''}`);
|
|
475
|
+
await events.use.emit('workflow:run:error', run);
|
|
476
|
+
logger.warn(`run=${runId} ${result.status}: ${toSource(result.error) ?? ''}`);
|
|
429
477
|
}
|
|
430
478
|
}
|
|
431
479
|
catch (err) {
|
|
432
480
|
run.status = 'failed';
|
|
433
|
-
run.error =
|
|
481
|
+
run.error = errorReason(err);
|
|
434
482
|
run.finishedAt = Date.now();
|
|
435
483
|
runStore.update(run);
|
|
436
|
-
await events.emit('workflow:run:error', run);
|
|
437
|
-
logger.error(`run=${runId} 异常: ${run.error}`);
|
|
484
|
+
await events.use.emit('workflow:run:error', run);
|
|
485
|
+
logger.error(`run=${runId} 异常: ${toSource(run.error)}`);
|
|
438
486
|
}
|
|
439
487
|
finally {
|
|
440
|
-
|
|
488
|
+
page.invalidate();
|
|
441
489
|
}
|
|
442
490
|
return run;
|
|
443
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* 触发器写错的原因:收下了也订阅不上、永远不会触发。cron 交 cron-engine 校验;interval 与定时任务的固定间隔同一条(大于 0、
|
|
494
|
+
* 不超过定时器能排的上限,超过的溢出成每毫秒触发);once 的 runAt 要能解析;event 不能订阅不可作触发源的事件
|
|
495
|
+
*/
|
|
496
|
+
function triggerError(trigger) {
|
|
497
|
+
switch (trigger.type) {
|
|
498
|
+
case 'cron': {
|
|
499
|
+
const checked = caps.cronEngine.use.validate(String(trigger.expr ?? ''));
|
|
500
|
+
return checked.ok ? null : checked.text;
|
|
501
|
+
}
|
|
502
|
+
case 'interval':
|
|
503
|
+
return validateIntervalSeconds(trigger.seconds);
|
|
504
|
+
case 'once':
|
|
505
|
+
return Number.isFinite(Date.parse(String(trigger.runAt)))
|
|
506
|
+
? null
|
|
507
|
+
: t('badRunAt', { runAt: String(trigger.runAt) });
|
|
508
|
+
case 'event':
|
|
509
|
+
return BLOCKED_EVENT_TRIGGERS.has(trigger.event) ? t('blockedEventTrigger', { event: trigger.event }) : null;
|
|
510
|
+
default:
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* 定义或覆盖:定义者记进账本,连同写进文件的文本的哈希;只在内存里的定义,定义者也只在内存。新定义的 id 不合规(它是新定义
|
|
516
|
+
* 文件的文件名;已载入的照原 id 改写它自己的文件)、图不合法、启用的定义触发器写错的,报错、不保存。停用的定义不注册触发器,
|
|
517
|
+
* 不校验它(定义目录里载入的、触发器写错的要能停用),启用时再校验
|
|
518
|
+
*/
|
|
519
|
+
async function define(def, persist, definer) {
|
|
520
|
+
const err = (loader.get(def.id) ? null : validateId(def.id)) ??
|
|
521
|
+
validateGraph(def) ??
|
|
522
|
+
(def.enabled === false ? null : triggerError(def.trigger));
|
|
523
|
+
if (err)
|
|
524
|
+
throw typeof err === 'string' ? new Error(err) : new TextError(err);
|
|
525
|
+
if (persist)
|
|
526
|
+
runStore.recordDefiner(def.id, definer, await loader.saveDef(def));
|
|
527
|
+
loader.putMemory(def, definer);
|
|
528
|
+
// 重新触发器
|
|
529
|
+
triggers.register(def);
|
|
530
|
+
page.invalidate();
|
|
531
|
+
logger.info(`workflow 已定义: ${def.id} (持久化=${persist})`);
|
|
532
|
+
}
|
|
533
|
+
const definitions = { define, definedBy: id => loader.definedBy(id) };
|
|
444
534
|
// ── WorkflowService ──
|
|
445
535
|
const service = {
|
|
446
536
|
listWorkflows() {
|
|
@@ -449,35 +539,29 @@ async function run(caps) {
|
|
|
449
539
|
getWorkflow(id) {
|
|
450
540
|
return loader.get(id);
|
|
451
541
|
},
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
throw new Error(err);
|
|
456
|
-
// 重新触发器
|
|
457
|
-
if (opts?.persist !== false) {
|
|
458
|
-
await loader.saveDef(def);
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
loader.putMemory(def);
|
|
462
|
-
}
|
|
463
|
-
triggers.register(def);
|
|
464
|
-
logger.info(`workflow 已定义: ${def.id} (持久化=${opts?.persist !== false})`);
|
|
542
|
+
defineWorkflow(def, opts) {
|
|
543
|
+
// 认不出调用方是谁:没有定义者
|
|
544
|
+
return define(def, opts?.persist !== false, undefined);
|
|
465
545
|
},
|
|
466
546
|
async removeWorkflow(id) {
|
|
547
|
+
// 只动已载入的定义,删的是它载入时的文件:别的 id(借 ../ 指到定义目录之外的、载入失败的)不碰存储
|
|
548
|
+
if (!loader.get(id))
|
|
549
|
+
return false;
|
|
467
550
|
// 先删文件,删不掉就抛错、触发器与 once 记账原样保留:先清账的话,重启后定义被重新载入,过期 once 会重放
|
|
468
|
-
|
|
551
|
+
await loader.removeDef(id);
|
|
469
552
|
triggers.unregister(id);
|
|
470
|
-
runStore.
|
|
471
|
-
|
|
553
|
+
runStore.forget(id); // 定义没了,once 记账与定义者不留孤儿:同 id 重建算新工作流
|
|
554
|
+
page.invalidate();
|
|
555
|
+
return true;
|
|
472
556
|
},
|
|
473
557
|
async runWorkflow(id, vars, source, caller) {
|
|
474
558
|
return await runById(id, vars ?? {}, source ?? 'manual', caller);
|
|
475
559
|
},
|
|
476
560
|
cancelRun(runId) {
|
|
477
|
-
const
|
|
478
|
-
if (!
|
|
561
|
+
const abort = aborts.get(runId);
|
|
562
|
+
if (!abort)
|
|
479
563
|
return false;
|
|
480
|
-
|
|
564
|
+
abort.abort();
|
|
481
565
|
return true;
|
|
482
566
|
},
|
|
483
567
|
getRun(runId) {
|
|
@@ -487,37 +571,41 @@ async function run(caps) {
|
|
|
487
571
|
return runStore.list(limit, workflowId);
|
|
488
572
|
},
|
|
489
573
|
};
|
|
490
|
-
provide(workflow, service);
|
|
574
|
+
provide.use(workflow, service);
|
|
575
|
+
const page = defineWorkflowPage(webui.use, service, definitions, () => runStore.onceLedgerReadable());
|
|
491
576
|
// ── AI 工具 ──
|
|
492
577
|
// tools 晚上线也无妨:登记口在提供者缺席时先挂账,上线后自动补挂
|
|
493
578
|
if (config.enableTools) {
|
|
494
|
-
registerTools(tools, service, () => runStore.onceLedgerReadable());
|
|
579
|
+
registerTools(tools.use, service, definitions, () => runStore.onceLedgerReadable());
|
|
495
580
|
}
|
|
496
|
-
// ── WebUI ──
|
|
497
|
-
for (const page of webuiPages)
|
|
498
|
-
webui.registerPage(page);
|
|
499
|
-
registerWebuiActions(webui, service, () => runStore.onceLedgerReadable());
|
|
500
581
|
// ── 清理 ──
|
|
501
|
-
lifecycle.onDispose(async () => {
|
|
582
|
+
lifecycle.use.onDispose(async () => {
|
|
502
583
|
triggers.dispose();
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
584
|
+
// 中止全部在飞 run:不再调度新节点,阻塞中的 wait / agent 节点立即结束。再等它们收尾、排队中的运行历史 /
|
|
585
|
+
// once 记账写完才结束拆卸:否则 app.stop() 返回后仍有落盘在飞,bounce 时迟到的快照会盖掉新实例的记账。
|
|
586
|
+
// 不响应中止信号的工具节点至多让这里等到 disposeTimeoutMs;放弃等待后旧实例记为 lingering,同 id 的新实例要等那次
|
|
587
|
+
// run 真正落定、这里返回之后才起
|
|
588
|
+
for (const abort of aborts.values())
|
|
589
|
+
abort.abort();
|
|
590
|
+
await Promise.allSettled(running);
|
|
509
591
|
await runStore.flushed();
|
|
510
592
|
});
|
|
511
593
|
logger.info(`工作流插件已启动 (${loader.list().length} 个定义已加载)`);
|
|
512
594
|
}
|
|
513
595
|
// ─── AI 工具注册 ───
|
|
514
596
|
/** onceLedgerReadable:once 记账读不出时本次运行不安排 once,workflow_define 的回执据此如实说明 */
|
|
515
|
-
function registerTools(tools, service, onceLedgerReadable) {
|
|
516
|
-
tools.registerGroup({
|
|
597
|
+
function registerTools(tools, service, definitions, onceLedgerReadable) {
|
|
598
|
+
tools.registerGroup({
|
|
599
|
+
name: 'workflow',
|
|
600
|
+
label: t('displayName'),
|
|
601
|
+
summary: t('groupSummary'),
|
|
602
|
+
description: '定义、运行、查询自主工作流(DAG)',
|
|
603
|
+
});
|
|
517
604
|
tools.register({
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
605
|
+
// 定义、跑、删编排:编排存下来以后按定义者的身份自己运行,节点能往任意会话发消息、开一轮。与子任务、定时任务、停后台任务
|
|
606
|
+
// 同一规则,2 级加确认,审批卡上是原文;别人单独发起的没人能批即拒,参与者都是 owner 且在免确认白名单里(或确认覆盖设为
|
|
607
|
+
// off)时免,批过的调用按批的人执行、定义者随之是他。列与看运行记录只看不改:2 级、不确认
|
|
608
|
+
risk: 'dangerous',
|
|
521
609
|
groups: ['workflow'],
|
|
522
610
|
definition: {
|
|
523
611
|
type: 'function',
|
|
@@ -538,7 +626,7 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
538
626
|
},
|
|
539
627
|
},
|
|
540
628
|
},
|
|
541
|
-
handler: async (args) => {
|
|
629
|
+
handler: async (args, callCtx) => {
|
|
542
630
|
const yamlText = String(args.yaml ?? '');
|
|
543
631
|
let raw;
|
|
544
632
|
try {
|
|
@@ -552,7 +640,9 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
552
640
|
return JSON.stringify({ error: '定义不合法:缺少 trigger 或 nodes' });
|
|
553
641
|
const persist = args.persist !== false;
|
|
554
642
|
try {
|
|
555
|
-
|
|
643
|
+
// 定义者取调用者的授权身份:经审批的是批的人,匿名的不记
|
|
644
|
+
const definedBy = identityOf(actingIdentity(callCtx));
|
|
645
|
+
await definitions.define(def, persist, definedBy);
|
|
556
646
|
}
|
|
557
647
|
catch (e) {
|
|
558
648
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
@@ -563,11 +653,12 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
563
653
|
id: def.id,
|
|
564
654
|
nodes: def.nodes.length,
|
|
565
655
|
trigger: def.trigger.type,
|
|
566
|
-
...(note ? { note } : {}),
|
|
656
|
+
...(note ? { note: toSource(note) } : {}),
|
|
567
657
|
});
|
|
568
658
|
},
|
|
569
659
|
});
|
|
570
660
|
tools.register({
|
|
661
|
+
visibility: 'restricted',
|
|
571
662
|
groups: ['workflow'],
|
|
572
663
|
definition: {
|
|
573
664
|
type: 'function',
|
|
@@ -590,7 +681,7 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
590
681
|
},
|
|
591
682
|
});
|
|
592
683
|
tools.register({
|
|
593
|
-
risk: '
|
|
684
|
+
risk: 'dangerous',
|
|
594
685
|
groups: ['workflow'],
|
|
595
686
|
definition: {
|
|
596
687
|
type: 'function',
|
|
@@ -608,17 +699,34 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
608
699
|
},
|
|
609
700
|
},
|
|
610
701
|
handler: async (args, callCtx) => {
|
|
702
|
+
const { signal } = callCtx;
|
|
703
|
+
const aborted = JSON.stringify({ error: '回合已中止,不再等' });
|
|
704
|
+
if (signal?.aborted)
|
|
705
|
+
return aborted;
|
|
611
706
|
try {
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
|
|
707
|
+
// 透传调用者的授权身份(这一项要 owner 确认:批过的是批的人,免了的是那位 owner);运行身份按它与定义者定(见 execute)。
|
|
708
|
+
// 调起这次运行的会话也带上:这一轮在等这次运行,指向它的 agent 节点立即失败。调用被中止(这一轮被停止键、禁言、/clear
|
|
709
|
+
// 等停下)时不再等这次运行,这一轮才落定得了;运行不取消
|
|
710
|
+
const run = await new Promise((resolve, reject) => {
|
|
711
|
+
const stop = () => resolve(undefined);
|
|
712
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
713
|
+
service
|
|
714
|
+
.runWorkflow(String(args.id), args.vars ?? {}, 'manual:tool', {
|
|
715
|
+
...actingIdentity(callCtx),
|
|
716
|
+
sessionId: callCtx.sessionId,
|
|
717
|
+
})
|
|
718
|
+
.then(resolve, reject)
|
|
719
|
+
.finally(() => signal?.removeEventListener('abort', stop));
|
|
720
|
+
});
|
|
721
|
+
if (!run)
|
|
722
|
+
return aborted;
|
|
615
723
|
return JSON.stringify({
|
|
616
724
|
ok: true,
|
|
617
725
|
runId: run.runId,
|
|
618
726
|
status: run.status,
|
|
619
|
-
durationMs: (run
|
|
620
|
-
nodes: run.nodes.map(n => ({ id: n.id, status: n.status, error: n.error })),
|
|
621
|
-
error: run.error,
|
|
727
|
+
durationMs: elapsed(run),
|
|
728
|
+
nodes: run.nodes.map(n => ({ id: n.id, status: n.status, error: toSource(n.error) })),
|
|
729
|
+
error: toSource(run.error),
|
|
622
730
|
});
|
|
623
731
|
}
|
|
624
732
|
catch (e) {
|
|
@@ -627,6 +735,8 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
627
735
|
},
|
|
628
736
|
});
|
|
629
737
|
tools.register({
|
|
738
|
+
// 只看不改:运行的出错原因带会话 id(agent 节点的几句都写目标会话,私聊会话的 id 带对方账号),与 scheduler_list_jobs 同档
|
|
739
|
+
visibility: 'restricted',
|
|
630
740
|
groups: ['workflow'],
|
|
631
741
|
definition: {
|
|
632
742
|
type: 'function',
|
|
@@ -652,14 +762,14 @@ function registerTools(tools, service, onceLedgerReadable) {
|
|
|
652
762
|
status: r.status,
|
|
653
763
|
triggerSource: r.triggerSource,
|
|
654
764
|
startedAt: new Date(r.startedAt).toISOString(),
|
|
655
|
-
durationMs: (r
|
|
656
|
-
error: r.error,
|
|
765
|
+
durationMs: elapsed(r),
|
|
766
|
+
error: toSource(r.error),
|
|
657
767
|
})),
|
|
658
768
|
});
|
|
659
769
|
},
|
|
660
770
|
});
|
|
661
771
|
tools.register({
|
|
662
|
-
risk: '
|
|
772
|
+
risk: 'dangerous',
|
|
663
773
|
groups: ['workflow'],
|
|
664
774
|
definition: {
|
|
665
775
|
type: 'function',
|