@aalis/plugin-workflow 0.15.0 → 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/index.js CHANGED
@@ -5,179 +5,207 @@
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
12
  import { isStorageUri, storage } from '@aalis/api-storage';
11
- import { tools } from '@aalis/api-tools';
13
+ import { actingIdentity, tools } from '@aalis/api-tools';
12
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
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 { TriggerManager } from './triggers.js';
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: '加载存储下的 *.yaml 定义(storage URI);AI 通过 workflow_define 创建的定义也写入此处。',
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: '保存最近 N 条运行实例(storage URI)。',
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: '超过则按时间裁剪最旧的;最小 10。',
46
+ description: t('maxRunsDescription'),
42
47
  },
43
48
  enableTools: {
44
49
  type: 'boolean',
45
- label: '注册 AI 工具',
50
+ label: t('enableToolsLabel'),
46
51
  default: true,
47
52
  onInvalid: 'error',
48
- description: '开启后向 LLM 暴露 workflow_define / workflow_run 等工具。',
53
+ description: t('enableToolsDescription'),
49
54
  },
50
55
  });
51
56
  // ─── 工具函数(与工作流页共享) ───
52
- function triggerSummary(t) {
53
- switch (t.type) {
57
+ function triggerSummary(trigger) {
58
+ switch (trigger.type) {
54
59
  case 'cron':
55
- return `cron ${t.expr ?? ''}`.trim();
60
+ return `cron ${trigger.expr ?? ''}`.trim();
56
61
  case 'interval':
57
- return `每 ${t.seconds ?? '?'}s`;
62
+ return t('everySeconds', { seconds: trigger.seconds ?? '?' });
58
63
  case 'once':
59
- return `一次性@${t.runAt ?? '?'}`;
64
+ return t('onceAt', { at: trigger.runAt ?? '?' });
60
65
  case 'event':
61
- return `event:${t.event ?? '?'}`;
66
+ return `event:${trigger.event ?? '?'}`;
62
67
  case 'manual':
63
- return '手动';
68
+ return t('manual');
64
69
  default:
65
- return t.type;
70
+ return trigger.type;
66
71
  }
67
72
  }
73
+ /** 有平台与用户 ID 的才是一个身份,缺一个即匿名 */
74
+ function identityOf(who) {
75
+ return who?.platform && who.userId ? { platform: who.platform, userId: who.userId } : undefined;
76
+ }
77
+ /** 定义表里的定义者:平台:用户 ID,没有的写「匿名」 */
78
+ const identityText = (who) => (who ? `${who.platform}:${who.userId}` : t('anonymous'));
79
+ /** 用时的写法:字典的 {ms, duration}(到秒,按读者的语言);没有用时写「—」 */
68
80
  function fmtDuration(ms) {
69
- if (!Number.isFinite(ms) || ms < 0)
70
- return '—';
71
- if (ms < 1000)
72
- return `${ms}ms`;
73
- if (ms < 60_000)
74
- return `${(ms / 1000).toFixed(1)}s`;
75
- const m = Math.floor(ms / 60_000);
76
- const s = Math.floor((ms % 60_000) / 1000);
77
- 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;
78
87
  }
79
88
  /**
80
- * once 记账读不出时本次运行不安排 once:定义或启用一个 once 的回执据此如实说明(AI 工具与工作流页同一口径)。
89
+ * once 记账读不出时本次运行不安排 once:定义或启用一个 once 的回执据此如实说明(AI 工具与工作流页同一口径,工具取原文)。
81
90
  * manualRun 是各自那一面手动执行的说法;不持久化的定义重启后就没了,「重启恢复」只对写了文件的定义成立。
82
91
  */
83
92
  function onceSkippedNote(def, persist, onceLedgerReadable, manualRun) {
84
93
  if (def.trigger.type !== 'once' || def.enabled === false || onceLedgerReadable)
85
94
  return undefined;
86
- return `once 记账读取失败(见日志),本次运行不会触发;可用${manualRun}手动执行${persist ? ',修复运行历史文件后重启恢复' : ''}`;
95
+ return t(persist ? 'onceSkippedPersisted' : 'onceSkipped', { manualRun });
87
96
  }
97
+ /** 工作流页的回执:once 记账读不出时接上附注 */
98
+ const withNote = (receipt, note) => (note ? textClauses([receipt, note]) : receipt);
88
99
  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' },
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' },
94
105
  };
95
106
  /**
96
107
  * 工作流页:处理函数是闭包,直接用本次激活构造的 WorkflowService,不按名回查容器(查到的可能是别人提供的同名服务)。
97
108
  * 返回页面的句柄:定义增删与运行起止时让它重取
98
109
  */
99
- function defineWorkflowPage(webui, service, onceLedgerReadable) {
110
+ function defineWorkflowPage(webui, service, definitions, onceLedgerReadable) {
100
111
  const definitionOf = (id) => {
101
112
  const def = service.getWorkflow(id);
102
113
  if (!def)
103
- throw new Error(`工作流 ${id} 不存在`);
114
+ throw new TextError(t('workflowMissing', { id }));
104
115
  return def;
105
116
  };
106
- /** 启用或停用:改的是写在磁盘上的定义 */
117
+ /** 启用或停用:改的是写在磁盘上的定义,定义者沿用原来的 */
107
118
  const setEnabled = async (id, enabled) => {
108
119
  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}` : ''}`;
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);
112
123
  };
113
124
  return webui.definePage({
114
125
  key: 'workflows',
115
- title: '工作流',
126
+ title: t('displayName'),
116
127
  icon: 'workflow',
117
128
  actions: {
118
129
  runWorkflow: {
119
- label: '立即运行',
130
+ label: t('runWorkflow'),
120
131
  effect: 'write',
121
132
  row: true,
122
- async handler({ rowKey }) {
123
- const run = await service.runWorkflow(rowKey, {}, 'manual:webui');
133
+ async handler({ rowKey, ctx }) {
134
+ // 调用者是登录的 owner:按定义者执行(见 execute 的运行身份)
135
+ const run = await service.runWorkflow(rowKey, {}, 'manual:webui', ctx.caller);
124
136
  if (run.status !== 'success') {
125
- throw new Error(`运行没有成功(${STATUS[run.status].text})${run.error ? `:${run.error}` : ''}`);
137
+ const status = STATUS[run.status].text;
138
+ throw new TextError(run.error ? t('runNotSucceededBecause', { status, error: run.error }) : t('runNotSucceeded', { status }));
126
139
  }
127
- return `已运行 ${rowKey},用时 ${fmtDuration((run.finishedAt ?? Date.now()) - run.startedAt)}`;
140
+ return t('ran', { id: rowKey, duration: fmtDuration(elapsed(run)) });
128
141
  },
129
142
  },
130
- enableWorkflow: { label: '启用', effect: 'write', row: true, handler: ({ rowKey }) => setEnabled(rowKey, true) },
143
+ enableWorkflow: {
144
+ label: t('enableAction'),
145
+ effect: 'write',
146
+ row: true,
147
+ handler: ({ rowKey }) => setEnabled(rowKey, true),
148
+ },
131
149
  disableWorkflow: {
132
- label: '停用',
150
+ label: t('disableAction'),
133
151
  effect: 'write',
134
152
  row: true,
135
153
  handler: ({ rowKey }) => setEnabled(rowKey, false),
136
154
  },
137
155
  removeWorkflow: {
138
- label: '删除',
156
+ label: t('delete'),
139
157
  effect: 'destructive',
140
- confirm: '删除这个工作流,连同磁盘上的定义文件?',
158
+ confirm: t('deleteConfirm'),
141
159
  row: true,
142
160
  async handler({ rowKey }) {
143
161
  if (!(await service.removeWorkflow(rowKey)))
144
- throw new Error(`工作流 ${rowKey} 不存在`);
145
- return `已删除 ${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 });
146
176
  },
147
177
  },
148
178
  defineWorkflow: {
149
- label: '新建或覆盖',
179
+ label: t('defineWorkflow'),
150
180
  effect: 'write',
151
181
  input: {
152
182
  yaml: {
153
183
  type: 'textarea',
154
- label: 'YAML 定义',
184
+ label: t('yamlLabel'),
155
185
  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 在定义表格的详情里,可以照着改。',
186
+ description: t('yamlDescription'),
159
187
  },
160
188
  persist: {
161
189
  type: 'boolean',
162
- label: '写入磁盘',
190
+ label: t('persistLabel'),
163
191
  default: true,
164
- description: '关闭后只在内存里注册,重启后丢失',
192
+ description: t('persistDescription'),
165
193
  },
166
194
  },
167
- async handler({ input }) {
195
+ async handler({ input, ctx }) {
168
196
  let raw;
169
197
  try {
170
198
  raw = parse(input.yaml);
171
199
  }
172
200
  catch (err) {
173
- throw new Error(`YAML 解析失败:${err instanceof Error ? err.message : String(err)}`);
201
+ throw new TextError(t('yamlFailed', { error: textOf(err) }));
174
202
  }
175
203
  const def = normalizeDef(raw, `wf-${Date.now()}`);
176
204
  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}` : ''}`;
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);
181
209
  },
182
210
  },
183
211
  },
@@ -189,12 +217,12 @@ function defineWorkflowPage(webui, service, onceLedgerReadable) {
189
217
  const defs = service.listWorkflows();
190
218
  const recent = service.listRuns(200).filter(r => Date.now() - r.startedAt < 86_400_000);
191
219
  return [
192
- { label: '定义', value: defs.length },
193
- { label: '启用', value: defs.filter(d => d.enabled !== false).length },
194
- { label: '24 小时运行', value: recent.length },
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 },
195
223
  {
196
- label: '24 小时失败',
197
- value: recent.filter(r => r.status === 'failed' || r.status === 'cancelled').length,
224
+ label: t('failures24h'),
225
+ value: recent.filter(r => r.status === 'failed').length,
198
226
  },
199
227
  ];
200
228
  },
@@ -202,16 +230,17 @@ function defineWorkflowPage(webui, service, onceLedgerReadable) {
202
230
  {
203
231
  type: 'table',
204
232
  id: 'workflows',
205
- label: '定义',
233
+ label: t('definitions'),
206
234
  rowKey: 'id',
207
- empty: '还没有工作流',
235
+ empty: t('noWorkflows'),
208
236
  columns: [
209
237
  { 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' },
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' },
215
244
  ],
216
245
  rowActions: [
217
246
  { action: 'runWorkflow' },
@@ -224,10 +253,13 @@ function defineWorkflowPage(webui, service, onceLedgerReadable) {
224
253
  name: d.name ?? d.id,
225
254
  trigger: triggerSummary(d.trigger),
226
255
  nodes: d.nodes.length,
227
- status: d.enabled === false ? { text: '停用', state: 'inactive' } : { text: '启用' },
256
+ status: d.enabled === false
257
+ ? { text: t('stateDisabled'), state: 'inactive' }
258
+ : { text: t('stateEnabled') },
228
259
  enabled: d.enabled !== false,
229
260
  disabled: d.enabled === false,
230
261
  description: d.description ?? '',
262
+ definedBy: identityText(definitions.definedBy(d.id)),
231
263
  })),
232
264
  detail: [
233
265
  {
@@ -241,26 +273,32 @@ function defineWorkflowPage(webui, service, onceLedgerReadable) {
241
273
  {
242
274
  type: 'table',
243
275
  id: 'runs',
244
- label: '最近运行',
276
+ label: t('recentRuns'),
245
277
  rowKey: 'runId',
246
- empty: '还没有运行过',
278
+ empty: t('noRuns'),
247
279
  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' },
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' },
255
287
  ],
256
- data: () => service.listRuns(50).map(r => ({
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 => ({
257
294
  runId: r.runId,
258
295
  workflowId: r.workflowId,
259
296
  status: STATUS[r.status],
260
297
  triggerSource: r.triggerSource,
261
298
  startedAt: r.startedAt,
262
- duration: fmtDuration((r.finishedAt ?? Date.now()) - r.startedAt),
299
+ duration: fmtDuration(elapsed(r)),
263
300
  error: r.error ?? '',
301
+ running: r.status === 'running',
264
302
  })),
265
303
  },
266
304
  { type: 'actions', actions: ['defineWorkflow'] },
@@ -273,7 +311,7 @@ function resolveUri(key, input, fallback) {
273
311
  if (!s)
274
312
  return fallback;
275
313
  if (!isStorageUri(s)) {
276
- throw configError(`${key}="${s}" 不是 storage URI,请写成 <根名>:/<路径>(如 ${fallback}),或删掉该键使用默认值`);
314
+ throw configError(t('badUri', { key, value: s, fallback }));
277
315
  }
278
316
  return s;
279
317
  }
@@ -287,9 +325,13 @@ const uses = {
287
325
  tools: optional(tools),
288
326
  /** 工作流页的登记口;无 WebUI 时引擎照常运行 */
289
327
  webui: optional(webuiServer),
328
+ /** 判调用者是不是 owner,定运行身份;缺席时判不出谁是 owner,调用者与定义者不是同一身份的按匿名 */
329
+ authority: optional(authority),
290
330
  events,
291
331
  /** agent 节点经 'agent:turn:after' 捕获本轮回复 */
292
332
  hooks,
333
+ /** agent 节点经它送达指令;缺席时 agent 节点失败 */
334
+ gateway: optional(gateway),
293
335
  lifecycle,
294
336
  logger,
295
337
  config,
@@ -297,7 +339,7 @@ const uses = {
297
339
  };
298
340
  export default definePlugin({
299
341
  name: '@aalis/plugin-workflow',
300
- displayName: '工作流',
342
+ displayName: t('displayName'),
301
343
  subsystem: 'workflow',
302
344
  extends: {
303
345
  events: ['workflow:run:start', 'workflow:run:done', 'workflow:run:error', 'workflow:node:done', 'trigger:fired'],
@@ -317,32 +359,35 @@ async function run(caps) {
317
359
  maxRuns: cfg.maxRuns > 0 ? cfg.maxRuns : configSchema.maxRuns.default,
318
360
  };
319
361
  const logger = caps.logger.use.child('workflow');
320
- const loader = new WorkflowLoader(caps.storage.use, config.defsDir, logger);
321
- const scanned = await loader.loadAll();
362
+ // 先读运行历史:载入定义时按里面的定义者账本认定义者
322
363
  const runStore = new RunStore(caps.storage.use, config.runsFile, config.maxRuns, logger);
323
364
  await runStore.init();
365
+ const loader = new WorkflowLoader(caps.storage.use, config.defsDir, logger);
366
+ const scanned = await loader.loadAll((id, sha256) => runStore.definerOf(id, sha256));
324
367
  /** 在飞 run 的取消开关,按 runId */
325
368
  const aborts = new Map();
326
369
  /** 在飞的 run:拆卸先中止,再等它们收尾,被取消的 run 也要把终态写进运行历史 */
327
370
  const running = new Set();
371
+ /** 定时与事件触发:以定义者作调用者 */
372
+ const runAsDefiner = (workflowId, vars, source) => runById(workflowId, vars, source, loader.definedBy(workflowId));
328
373
  // ── 触发管理器:内部 cron/interval/event 监听 ──
329
374
  const triggers = new TriggerManager({ cronEngine: caps.cronEngine, events: events.use, logger }, (workflowId, source, payload) => {
330
375
  // 异步触发,不阻塞触发源;event 触发的 payload({args})作为 vars 注入,{{vars.X}} 才读得到
331
- runById(workflowId, payload ?? {}, source).catch(err => {
376
+ runAsDefiner(workflowId, payload ?? {}, source).catch(err => {
332
377
  logger.error(`触发执行 workflow=${workflowId} source=${source} 失败: ${err instanceof Error ? err.message : err}`);
333
378
  });
334
379
  }, runStore);
335
- // 定义已从磁盘删除的,once 记账不留孤儿(手删 yaml 不经 removeWorkflow);注册前清。
380
+ // 定义已从磁盘删除的,once 记账与定义者不留孤儿(手删 yaml 不经 removeWorkflow);注册前清。
336
381
  // 列目录失败那次启动看到的是空集,不能据此清账——否则下次启动所有过期 once 全部重放
337
382
  if (scanned)
338
- runStore.pruneOnceFired(new Set(loader.list().map(d => d.id)));
383
+ runStore.prune(new Set(loader.list().map(d => d.id)));
339
384
  for (const def of loader.list())
340
385
  triggers.register(def);
341
386
  // ── 订阅外部 trigger:fired(来自 scheduler / 其他触发源)──
342
387
  events.use.on('trigger:fired', info => {
343
388
  if (!info?.workflowId)
344
389
  return;
345
- runById(info.workflowId, info.payload ?? {}, info.source ?? `trigger:${info.type}`).catch(err => {
390
+ runAsDefiner(info.workflowId, info.payload ?? {}, info.source ?? `trigger:${info.type}`).catch(err => {
346
391
  logger.error(`trigger:fired workflow=${info.workflowId} 执行失败: ${err instanceof Error ? err.message : err}`);
347
392
  });
348
393
  });
@@ -354,12 +399,24 @@ async function run(caps) {
354
399
  pending.then(settle, settle);
355
400
  return pending;
356
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
+ }
357
413
  async function execute(workflowId, extraVars, triggerSource, caller) {
358
414
  const def = loader.get(workflowId);
359
415
  if (!def)
360
- throw new Error(`workflow "${workflowId}" 不存在`);
416
+ throw new TextError(t('runMissing', { id: workflowId }));
361
417
  if (def.enabled === false)
362
- throw new Error(`workflow "${workflowId}" 已禁用`);
418
+ throw new TextError(t('runDisabled', { id: workflowId }));
419
+ const actor = runIdentity(identityOf(caller), loader.definedBy(workflowId));
363
420
  const runId = `${workflowId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
364
421
  const vars = { ...(def.vars ?? {}), ...extraVars };
365
422
  const abort = new AbortController();
@@ -378,24 +435,25 @@ async function run(caps) {
378
435
  await events.use.emit('workflow:run:start', run);
379
436
  logger.info(`run=${runId} 启动 (trigger=${triggerSource})`);
380
437
  try {
438
+ // 跑完就摘掉中止句柄,再记终态、发收尾事件:之后的 cancelRun 回 false,不对已经落定的运行回「已取消」
381
439
  const result = await runDag({
382
440
  tools,
383
441
  events: events.use,
384
442
  hooks: hooks.use,
443
+ gateway: caps.gateway,
385
444
  logger,
386
445
  def,
387
446
  runId,
388
447
  vars,
389
- // 运行时身份:经 workflow_run 工具触发时以 actor 透传【调用者】授权身份,使工作流
390
- // 内部工具与 agent/send_message 节点按【调用者】的权限等级过 authority 闸(而非匿名
391
- // level-0);owner 定义、谁调按谁裁决,杜绝借他人 workflow 提权。platform 保持
392
- // 'workflow' 会话语义(不再借调用者平台——那会污染定时任务归属/审批的发起类别等分流)。
393
- // cron/event/webui 触发无调用者 → 保持匿名(仅能跑 public 工具)。
448
+ // 运行身份是工具调用上下文的唯一参与者,交给工作流内部工具与 agent/send_message 节点,按它的等级过 authority 闸;
449
+ // 匿名的是无主体,只能跑 public 工具。platform 保持 'workflow' 会话语义(不借调用者平台——那会污染定时任务归属/审批的
450
+ // 发起类别等分流)。
394
451
  toolCallContext: {
395
452
  sessionId: `workflow::${workflowId}`,
396
453
  platform: 'workflow',
397
- actor: caller?.platform && caller?.userId ? { platform: caller.platform, userId: caller.userId } : undefined,
454
+ participants: [actor ?? { platform: 'workflow', userId: '' }],
398
455
  },
456
+ callerSessionId: caller?.sessionId,
399
457
  signal: abort.signal,
400
458
  onNodeDone: (info) => {
401
459
  const idx = run.nodes.findIndex(n => n.id === info.id);
@@ -403,7 +461,7 @@ async function run(caps) {
403
461
  run.nodes[idx] = info;
404
462
  events.use.emit('workflow:node:done', { runId, node: info }).catch(() => { });
405
463
  },
406
- });
464
+ }).finally(() => aborts.delete(runId));
407
465
  run.status = result.status;
408
466
  run.nodes = result.nodes;
409
467
  run.error = result.error;
@@ -415,23 +473,64 @@ async function run(caps) {
415
473
  }
416
474
  else {
417
475
  await events.use.emit('workflow:run:error', run);
418
- logger.warn(`run=${runId} ${result.status}: ${result.error ?? ''}`);
476
+ logger.warn(`run=${runId} ${result.status}: ${toSource(result.error) ?? ''}`);
419
477
  }
420
478
  }
421
479
  catch (err) {
422
480
  run.status = 'failed';
423
- run.error = err instanceof Error ? err.message : String(err);
481
+ run.error = errorReason(err);
424
482
  run.finishedAt = Date.now();
425
483
  runStore.update(run);
426
484
  await events.use.emit('workflow:run:error', run);
427
- logger.error(`run=${runId} 异常: ${run.error}`);
485
+ logger.error(`run=${runId} 异常: ${toSource(run.error)}`);
428
486
  }
429
487
  finally {
430
- aborts.delete(runId);
431
488
  page.invalidate();
432
489
  }
433
490
  return run;
434
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) };
435
534
  // ── WorkflowService ──
436
535
  const service = {
437
536
  listWorkflows() {
@@ -440,28 +539,20 @@ async function run(caps) {
440
539
  getWorkflow(id) {
441
540
  return loader.get(id);
442
541
  },
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
- page.invalidate();
456
- logger.info(`workflow 已定义: ${def.id} (持久化=${opts?.persist !== false})`);
542
+ defineWorkflow(def, opts) {
543
+ // 认不出调用方是谁:没有定义者
544
+ return define(def, opts?.persist !== false, undefined);
457
545
  },
458
546
  async removeWorkflow(id) {
547
+ // 只动已载入的定义,删的是它载入时的文件:别的 id(借 ../ 指到定义目录之外的、载入失败的)不碰存储
548
+ if (!loader.get(id))
549
+ return false;
459
550
  // 先删文件,删不掉就抛错、触发器与 once 记账原样保留:先清账的话,重启后定义被重新载入,过期 once 会重放
460
- const had = await loader.removeDef(id);
551
+ await loader.removeDef(id);
461
552
  triggers.unregister(id);
462
- runStore.clearOnceFired(id); // 定义没了,once 记账不留孤儿:同 id 重建算新工作流
553
+ runStore.forget(id); // 定义没了,once 记账与定义者不留孤儿:同 id 重建算新工作流
463
554
  page.invalidate();
464
- return had;
555
+ return true;
465
556
  },
466
557
  async runWorkflow(id, vars, source, caller) {
467
558
  return await runById(id, vars ?? {}, source ?? 'manual', caller);
@@ -481,11 +572,11 @@ async function run(caps) {
481
572
  },
482
573
  };
483
574
  provide.use(workflow, service);
484
- const page = defineWorkflowPage(webui.use, service, () => runStore.onceLedgerReadable());
575
+ const page = defineWorkflowPage(webui.use, service, definitions, () => runStore.onceLedgerReadable());
485
576
  // ── AI 工具 ──
486
577
  // tools 晚上线也无妨:登记口在提供者缺席时先挂账,上线后自动补挂
487
578
  if (config.enableTools) {
488
- registerTools(tools.use, service, () => runStore.onceLedgerReadable());
579
+ registerTools(tools.use, service, definitions, () => runStore.onceLedgerReadable());
489
580
  }
490
581
  // ── 清理 ──
491
582
  lifecycle.use.onDispose(async () => {
@@ -503,12 +594,18 @@ async function run(caps) {
503
594
  }
504
595
  // ─── AI 工具注册 ───
505
596
  /** onceLedgerReadable:once 记账读不出时本次运行不安排 once,workflow_define 的回执据此如实说明 */
506
- function registerTools(tools, service, onceLedgerReadable) {
507
- tools.registerGroup({ name: 'workflow', label: '工作流', description: '定义、运行、查询自主工作流(DAG)' });
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
+ });
508
604
  tools.register({
509
- // 编排定义/触发/删除:可定义向任意 sessionId 发消息/跑 agent 的工作流,挡住不受信任访客经
510
- // LLM 驱动。sensitive(L1),不加逐次 confirm。只读 list/get_runs 保持 public。
511
- risk: 'sensitive',
605
+ // 定义、跑、删编排:编排存下来以后按定义者的身份自己运行,节点能往任意会话发消息、开一轮。与子任务、定时任务、停后台任务
606
+ // 同一规则,2 级加确认,审批卡上是原文;别人单独发起的没人能批即拒,参与者都是 owner 且在免确认白名单里(或确认覆盖设为
607
+ // off)时免,批过的调用按批的人执行、定义者随之是他。列与看运行记录只看不改:2 级、不确认
608
+ risk: 'dangerous',
512
609
  groups: ['workflow'],
513
610
  definition: {
514
611
  type: 'function',
@@ -529,7 +626,7 @@ function registerTools(tools, service, onceLedgerReadable) {
529
626
  },
530
627
  },
531
628
  },
532
- handler: async (args) => {
629
+ handler: async (args, callCtx) => {
533
630
  const yamlText = String(args.yaml ?? '');
534
631
  let raw;
535
632
  try {
@@ -543,7 +640,9 @@ function registerTools(tools, service, onceLedgerReadable) {
543
640
  return JSON.stringify({ error: '定义不合法:缺少 trigger 或 nodes' });
544
641
  const persist = args.persist !== false;
545
642
  try {
546
- await service.defineWorkflow(def, { persist });
643
+ // 定义者取调用者的授权身份:经审批的是批的人,匿名的不记
644
+ const definedBy = identityOf(actingIdentity(callCtx));
645
+ await definitions.define(def, persist, definedBy);
547
646
  }
548
647
  catch (e) {
549
648
  return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
@@ -554,11 +653,12 @@ function registerTools(tools, service, onceLedgerReadable) {
554
653
  id: def.id,
555
654
  nodes: def.nodes.length,
556
655
  trigger: def.trigger.type,
557
- ...(note ? { note } : {}),
656
+ ...(note ? { note: toSource(note) } : {}),
558
657
  });
559
658
  },
560
659
  });
561
660
  tools.register({
661
+ visibility: 'restricted',
562
662
  groups: ['workflow'],
563
663
  definition: {
564
664
  type: 'function',
@@ -581,7 +681,7 @@ function registerTools(tools, service, onceLedgerReadable) {
581
681
  },
582
682
  });
583
683
  tools.register({
584
- risk: 'sensitive',
684
+ risk: 'dangerous',
585
685
  groups: ['workflow'],
586
686
  definition: {
587
687
  type: 'function',
@@ -599,17 +699,34 @@ function registerTools(tools, service, onceLedgerReadable) {
599
699
  },
600
700
  },
601
701
  handler: async (args, callCtx) => {
702
+ const { signal } = callCtx;
703
+ const aborted = JSON.stringify({ error: '回合已中止,不再等' });
704
+ if (signal?.aborted)
705
+ return aborted;
602
706
  try {
603
- // 透传调用者身份:工作流内部工具按调用者权限跑(owner 定义、谁调用按谁的档位裁决)。
604
- // 优先 callCtx.actor(本回合已在代人执行时链式传递),否则物理身份。
605
- const run = await service.runWorkflow(String(args.id), args.vars ?? {}, 'manual:tool', callCtx.actor ?? { platform: callCtx.platform, userId: callCtx.userId });
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;
606
723
  return JSON.stringify({
607
724
  ok: true,
608
725
  runId: run.runId,
609
726
  status: run.status,
610
- durationMs: (run.finishedAt ?? Date.now()) - run.startedAt,
611
- nodes: run.nodes.map(n => ({ id: n.id, status: n.status, error: n.error })),
612
- 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),
613
730
  });
614
731
  }
615
732
  catch (e) {
@@ -618,6 +735,8 @@ function registerTools(tools, service, onceLedgerReadable) {
618
735
  },
619
736
  });
620
737
  tools.register({
738
+ // 只看不改:运行的出错原因带会话 id(agent 节点的几句都写目标会话,私聊会话的 id 带对方账号),与 scheduler_list_jobs 同档
739
+ visibility: 'restricted',
621
740
  groups: ['workflow'],
622
741
  definition: {
623
742
  type: 'function',
@@ -643,14 +762,14 @@ function registerTools(tools, service, onceLedgerReadable) {
643
762
  status: r.status,
644
763
  triggerSource: r.triggerSource,
645
764
  startedAt: new Date(r.startedAt).toISOString(),
646
- durationMs: (r.finishedAt ?? Date.now()) - r.startedAt,
647
- error: r.error,
765
+ durationMs: elapsed(r),
766
+ error: toSource(r.error),
648
767
  })),
649
768
  });
650
769
  },
651
770
  });
652
771
  tools.register({
653
- risk: 'sensitive',
772
+ risk: 'dangerous',
654
773
  groups: ['workflow'],
655
774
  definition: {
656
775
  type: 'function',