myagent-ai 1.31.3 → 1.32.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/core/workflow_engine.py +1002 -0
- package/package.json +1 -1
- package/web/api_server.py +181 -0
- package/web/ui/admin/admin-core.js +1 -1
- package/web/ui/admin/admin-workflows.js +621 -0
- package/web/ui/index.html +2 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core/workflow_engine.py - 工作流引擎模块
|
|
3
|
+
==========================================
|
|
4
|
+
类似 GitHub Actions 的 Agent 工作流系统,支持:
|
|
5
|
+
- 每个 Agent 拥有独立工作流空间
|
|
6
|
+
- 步骤式执行 (browser / shell / api / condition / delay ...)
|
|
7
|
+
- 模板变量引用 {{steps.N.result}} / {{variables.xxx}}
|
|
8
|
+
- 完整的执行记录与日志
|
|
9
|
+
- 线程安全操作
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from core.logger import get_logger
|
|
27
|
+
from core.utils import generate_id, sanitize_filename, timestamp, truncate_str
|
|
28
|
+
|
|
29
|
+
logger = get_logger("myagent.workflow")
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# 路径安全:防止 agent_path / workflow_id 中的目录遍历攻击
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
_SAFE_PATH_RE = re.compile(r"^[\w.\-]+$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_path_segment(value: str, field_name: str = "path") -> str:
|
|
38
|
+
"""校验路径段,只允许 字母/数字/下划线/点/横线,防止目录遍历。"""
|
|
39
|
+
if not value or not _SAFE_PATH_RE.match(value):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"非法 {field_name}: '{value}',只允许字母、数字、下划线、点和横线"
|
|
42
|
+
)
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# 安全表达式求值 (用于 condition 步骤)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
_ALLOWED_BUILTINS = {
|
|
50
|
+
"len": len,
|
|
51
|
+
"str": str,
|
|
52
|
+
"int": int,
|
|
53
|
+
"float": float,
|
|
54
|
+
"bool": bool,
|
|
55
|
+
"list": list,
|
|
56
|
+
"dict": dict,
|
|
57
|
+
"abs": abs,
|
|
58
|
+
"min": min,
|
|
59
|
+
"max": max,
|
|
60
|
+
"sum": sum,
|
|
61
|
+
"round": round,
|
|
62
|
+
"True": True,
|
|
63
|
+
"False": False,
|
|
64
|
+
"None": None,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _safe_eval(expression: str, context: Dict) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
安全地求值条件表达式。
|
|
71
|
+
|
|
72
|
+
只允许简单的比较和算术运算,通过 AST 白名单限制可用的节点类型。
|
|
73
|
+
context 中包含 ``steps`` 字典和 ``variables`` 字典。
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
tree = ast.parse(expression.strip(), mode="eval")
|
|
77
|
+
except SyntaxError as exc:
|
|
78
|
+
raise ValueError(f"条件表达式语法错误: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
# 仅允许的 AST 节点类型
|
|
81
|
+
allowed_nodes = (
|
|
82
|
+
ast.Expression,
|
|
83
|
+
ast.BoolOp,
|
|
84
|
+
ast.And,
|
|
85
|
+
ast.Or,
|
|
86
|
+
ast.Not,
|
|
87
|
+
ast.Compare,
|
|
88
|
+
ast.Eq,
|
|
89
|
+
ast.NotEq,
|
|
90
|
+
ast.Lt,
|
|
91
|
+
ast.LtE,
|
|
92
|
+
ast.Gt,
|
|
93
|
+
ast.GtE,
|
|
94
|
+
ast.In,
|
|
95
|
+
ast.NotIn,
|
|
96
|
+
ast.Is,
|
|
97
|
+
ast.IsNot,
|
|
98
|
+
ast.Call,
|
|
99
|
+
ast.Constant,
|
|
100
|
+
ast.Name,
|
|
101
|
+
ast.Attribute,
|
|
102
|
+
ast.Subscript,
|
|
103
|
+
ast.Load,
|
|
104
|
+
ast.UnaryOp,
|
|
105
|
+
ast.UAdd,
|
|
106
|
+
ast.USub,
|
|
107
|
+
ast.BinOp,
|
|
108
|
+
ast.Add,
|
|
109
|
+
ast.Sub,
|
|
110
|
+
ast.Mult,
|
|
111
|
+
ast.Div,
|
|
112
|
+
ast.Mod,
|
|
113
|
+
ast.Num,
|
|
114
|
+
ast.Str,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
for node in ast.walk(tree):
|
|
118
|
+
if not isinstance(node, allowed_nodes):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"条件表达式包含不允许的操作: {ast.dump(node)}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# 编译并执行
|
|
124
|
+
code = compile(tree, "<condition>", "eval")
|
|
125
|
+
result = eval(code, {"__builtins__": _ALLOWED_BUILTINS}, context) # noqa: S307
|
|
126
|
+
return bool(result)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ===========================================================================
|
|
130
|
+
# WorkflowEngine
|
|
131
|
+
# ===========================================================================
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WorkflowEngine:
|
|
135
|
+
"""工作流引擎:管理 Agent 工作流的 CRUD 与执行。
|
|
136
|
+
|
|
137
|
+
存储布局::
|
|
138
|
+
|
|
139
|
+
<data_dir>/workflows/<agent_path>/<workflow_id>.json
|
|
140
|
+
<data_dir>/workflows/<agent_path>/runs/<run_id>.json
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, data_dir: Path | str | None = None) -> None:
|
|
144
|
+
if data_dir is None:
|
|
145
|
+
data_dir = Path.home() / ".myagent" / "data"
|
|
146
|
+
self._data_dir = Path(data_dir)
|
|
147
|
+
self._workflows_base = self._data_dir / "workflows"
|
|
148
|
+
self._lock = threading.Lock()
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# 内部路径辅助
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _agent_dir(self, agent_path: str) -> Path:
|
|
155
|
+
"""返回 Agent 的工作流目录。"""
|
|
156
|
+
_safe_path_segment(agent_path, "agent_path")
|
|
157
|
+
return self._workflows_base / agent_path
|
|
158
|
+
|
|
159
|
+
def _workflow_file(self, agent_path: str, workflow_id: str) -> Path:
|
|
160
|
+
_safe_path_segment(workflow_id, "workflow_id")
|
|
161
|
+
return self._agent_dir(agent_path) / f"{workflow_id}.json"
|
|
162
|
+
|
|
163
|
+
def _runs_dir(self, agent_path: str) -> Path:
|
|
164
|
+
return self._agent_dir(agent_path) / "runs"
|
|
165
|
+
|
|
166
|
+
def _run_file(self, agent_path: str, run_id: str) -> Path:
|
|
167
|
+
_safe_path_segment(run_id, "run_id")
|
|
168
|
+
return self._runs_dir(agent_path) / f"{run_id}.json"
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# 读取 / 写入工具
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _read_json(path: Path) -> Optional[Dict]:
|
|
176
|
+
"""安全读取 JSON 文件。"""
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
179
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:
|
|
180
|
+
logger.warning("读取 JSON 失败 %s: %s", path, exc)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _write_json(path: Path, data: Dict) -> None:
|
|
185
|
+
"""写入 JSON 文件(pretty-print)。"""
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
path.write_text(
|
|
188
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
189
|
+
encoding="utf-8",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# CRUD
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def list_workflows(self, agent_path: str | None = None) -> List[Dict]:
|
|
197
|
+
"""列出工作流。若 ``agent_path`` 为 None 则扫描所有 Agent。"""
|
|
198
|
+
workflows: List[Dict] = []
|
|
199
|
+
if agent_path is not None:
|
|
200
|
+
base = self._agent_dir(agent_path)
|
|
201
|
+
search_dirs: List[Path] = [base] if base.is_dir() else []
|
|
202
|
+
else:
|
|
203
|
+
if not self._workflows_base.is_dir():
|
|
204
|
+
return workflows
|
|
205
|
+
search_dirs = [
|
|
206
|
+
p for p in self._workflows_base.iterdir() if p.is_dir()
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
for agent_dir in search_dirs:
|
|
210
|
+
for fp in sorted(agent_dir.glob("wf_*.json")):
|
|
211
|
+
wf = self._read_json(fp)
|
|
212
|
+
if wf:
|
|
213
|
+
workflows.append(wf)
|
|
214
|
+
return workflows
|
|
215
|
+
|
|
216
|
+
def get_workflow(
|
|
217
|
+
self, agent_path: str, workflow_id: str
|
|
218
|
+
) -> Optional[Dict]:
|
|
219
|
+
"""获取单个工作流。"""
|
|
220
|
+
path = self._workflow_file(agent_path, workflow_id)
|
|
221
|
+
return self._read_json(path)
|
|
222
|
+
|
|
223
|
+
def create_workflow(self, agent_path: str, data: Dict) -> Dict:
|
|
224
|
+
"""创建新工作流,自动生成 ID。"""
|
|
225
|
+
_safe_path_segment(agent_path, "agent_path")
|
|
226
|
+
|
|
227
|
+
now = timestamp()
|
|
228
|
+
wf_id = f"wf_{int(time.time())}_{generate_id()[:6]}"
|
|
229
|
+
|
|
230
|
+
# 构建工作流对象
|
|
231
|
+
workflow: Dict[str, Any] = {
|
|
232
|
+
"id": wf_id,
|
|
233
|
+
"name": data.get("name", "未命名工作流"),
|
|
234
|
+
"description": data.get("description", ""),
|
|
235
|
+
"agent_path": agent_path,
|
|
236
|
+
"enabled": data.get("enabled", True),
|
|
237
|
+
"schedule": data.get("schedule", ""),
|
|
238
|
+
"steps": data.get("steps", []),
|
|
239
|
+
"variables": data.get("variables", {}),
|
|
240
|
+
"created_at": now,
|
|
241
|
+
"updated_at": now,
|
|
242
|
+
"last_run": None,
|
|
243
|
+
"last_status": None,
|
|
244
|
+
"run_count": 0,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
path = self._workflow_file(agent_path, wf_id)
|
|
248
|
+
with self._lock:
|
|
249
|
+
self._write_json(path, workflow)
|
|
250
|
+
|
|
251
|
+
logger.info(
|
|
252
|
+
"创建工作流 [%s] %s (agent=%s)", wf_id, workflow["name"], agent_path
|
|
253
|
+
)
|
|
254
|
+
return workflow
|
|
255
|
+
|
|
256
|
+
def update_workflow(
|
|
257
|
+
self, agent_path: str, workflow_id: str, data: Dict
|
|
258
|
+
) -> Dict:
|
|
259
|
+
"""更新工作流字段和步骤。"""
|
|
260
|
+
path = self._workflow_file(agent_path, workflow_id)
|
|
261
|
+
with self._lock:
|
|
262
|
+
workflow = self._read_json(path)
|
|
263
|
+
if workflow is None:
|
|
264
|
+
raise FileNotFoundError(
|
|
265
|
+
f"工作流不存在: {agent_path}/{workflow_id}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# 可更新的字段
|
|
269
|
+
updatable_fields = (
|
|
270
|
+
"name", "description", "enabled", "schedule",
|
|
271
|
+
"steps", "variables",
|
|
272
|
+
)
|
|
273
|
+
for field in updatable_fields:
|
|
274
|
+
if field in data:
|
|
275
|
+
workflow[field] = data[field]
|
|
276
|
+
|
|
277
|
+
workflow["updated_at"] = timestamp()
|
|
278
|
+
self._write_json(path, workflow)
|
|
279
|
+
|
|
280
|
+
logger.info(
|
|
281
|
+
"更新工作流 [%s] %s", workflow_id, workflow.get("name")
|
|
282
|
+
)
|
|
283
|
+
return workflow
|
|
284
|
+
|
|
285
|
+
def delete_workflow(self, agent_path: str, workflow_id: str) -> bool:
|
|
286
|
+
"""删除工作流及其运行记录。"""
|
|
287
|
+
path = self._workflow_file(agent_path, workflow_id)
|
|
288
|
+
runs_dir = self._runs_dir(agent_path)
|
|
289
|
+
|
|
290
|
+
with self._lock:
|
|
291
|
+
if not path.exists():
|
|
292
|
+
return False
|
|
293
|
+
path.unlink()
|
|
294
|
+
logger.info("删除工作流 [%s]", workflow_id)
|
|
295
|
+
|
|
296
|
+
# 清理关联的运行记录
|
|
297
|
+
if runs_dir.is_dir():
|
|
298
|
+
prefix = f"{workflow_id}_"
|
|
299
|
+
removed = 0
|
|
300
|
+
for fp in runs_dir.glob(f"{prefix}*.json"):
|
|
301
|
+
fp.unlink()
|
|
302
|
+
removed += 1
|
|
303
|
+
if removed:
|
|
304
|
+
logger.info(
|
|
305
|
+
"清理工作流 [%s] 的 %d 条运行记录", workflow_id, removed
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
def duplicate_workflow(
|
|
311
|
+
self,
|
|
312
|
+
agent_path: str,
|
|
313
|
+
workflow_id: str,
|
|
314
|
+
new_name: str | None = None,
|
|
315
|
+
) -> Dict:
|
|
316
|
+
"""复制工作流,生成新的 ID。"""
|
|
317
|
+
src = self.get_workflow(agent_path, workflow_id)
|
|
318
|
+
if src is None:
|
|
319
|
+
raise FileNotFoundError(
|
|
320
|
+
f"工作流不存在: {agent_path}/{workflow_id}"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
copy_data = {
|
|
324
|
+
"name": new_name or f"{src['name']} (副本)",
|
|
325
|
+
"description": src.get("description", ""),
|
|
326
|
+
"enabled": False, # 副本默认禁用,防止误触
|
|
327
|
+
"schedule": src.get("schedule", ""),
|
|
328
|
+
"steps": src.get("steps", []),
|
|
329
|
+
"variables": dict(src.get("variables", {})),
|
|
330
|
+
}
|
|
331
|
+
return self.create_workflow(agent_path, copy_data)
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------
|
|
334
|
+
# 模板渲染
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def _render_template(self, template: str, context: Dict) -> str:
|
|
338
|
+
"""替换模板变量::
|
|
339
|
+
|
|
340
|
+
{{steps.N.result}} → 步骤 N 的执行结果
|
|
341
|
+
{{steps.N.error}} → 步骤 N 的错误信息
|
|
342
|
+
{{variables.xxx}} → 工作流变量
|
|
343
|
+
{{agent_path}} → 当前 Agent 路径
|
|
344
|
+
{{now}} → 当前时间 ISO 格式
|
|
345
|
+
"""
|
|
346
|
+
if not template or "{{" not in template:
|
|
347
|
+
return template
|
|
348
|
+
|
|
349
|
+
def _replacer(match: re.Match) -> str:
|
|
350
|
+
expr = match.group(1).strip()
|
|
351
|
+
try:
|
|
352
|
+
# steps.N.result / steps.N.error
|
|
353
|
+
if expr.startswith("steps."):
|
|
354
|
+
parts = expr.split(".", 2)
|
|
355
|
+
if len(parts) >= 3:
|
|
356
|
+
step_idx = int(parts[1])
|
|
357
|
+
field = parts[2]
|
|
358
|
+
step_result = context.get("steps_results", {}).get(
|
|
359
|
+
step_idx, {}
|
|
360
|
+
)
|
|
361
|
+
val = step_result.get(field, "")
|
|
362
|
+
return str(val) if val is not None else ""
|
|
363
|
+
return ""
|
|
364
|
+
|
|
365
|
+
# variables.xxx
|
|
366
|
+
if expr.startswith("variables."):
|
|
367
|
+
var_name = expr[len("variables."):]
|
|
368
|
+
val = context.get("variables", {}).get(var_name, "")
|
|
369
|
+
return str(val) if val is not None else ""
|
|
370
|
+
|
|
371
|
+
# 内置变量
|
|
372
|
+
if expr == "agent_path":
|
|
373
|
+
return str(context.get("agent_path", ""))
|
|
374
|
+
if expr == "now":
|
|
375
|
+
return timestamp()
|
|
376
|
+
|
|
377
|
+
# 尝试从 context 顶层取值
|
|
378
|
+
if expr in context:
|
|
379
|
+
return str(context[expr])
|
|
380
|
+
|
|
381
|
+
return ""
|
|
382
|
+
except (KeyError, ValueError, IndexError, TypeError) as exc:
|
|
383
|
+
logger.debug("模板渲染失败 '%s': %s", expr, exc)
|
|
384
|
+
return ""
|
|
385
|
+
|
|
386
|
+
return re.sub(r"\{\{(.*?)\}\}", _replacer, template)
|
|
387
|
+
|
|
388
|
+
def _render_params(
|
|
389
|
+
self, params: Dict[str, Any], context: Dict
|
|
390
|
+
) -> Dict[str, Any]:
|
|
391
|
+
"""递归渲染参数字典中所有字符串值。"""
|
|
392
|
+
rendered: Dict[str, Any] = {}
|
|
393
|
+
for key, value in params.items():
|
|
394
|
+
if isinstance(value, str):
|
|
395
|
+
rendered[key] = self._render_template(value, context)
|
|
396
|
+
elif isinstance(value, dict):
|
|
397
|
+
rendered[key] = self._render_params(value, context)
|
|
398
|
+
elif isinstance(value, list):
|
|
399
|
+
rendered[key] = [
|
|
400
|
+
self._render_template(v, context)
|
|
401
|
+
if isinstance(v, str)
|
|
402
|
+
else v
|
|
403
|
+
for v in value
|
|
404
|
+
]
|
|
405
|
+
else:
|
|
406
|
+
rendered[key] = value
|
|
407
|
+
return rendered
|
|
408
|
+
|
|
409
|
+
# ------------------------------------------------------------------
|
|
410
|
+
# 步骤执行
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
def _execute_step(self, step: Dict, context: Dict) -> Dict:
|
|
414
|
+
"""执行单个步骤。
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
step: 步骤定义 ``{"id": N, "type": "...", "params": {...}}``
|
|
418
|
+
context: 执行上下文,包含 ``steps_results``, ``variables``,
|
|
419
|
+
``agent_path``, ``browser_page`` 等
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
``{"status": "success"|"failed"|"skipped", "result": ..., "error": ...}``
|
|
423
|
+
"""
|
|
424
|
+
step_type = step.get("type", "")
|
|
425
|
+
params = self._render_params(step.get("params", {}) or {}, context)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
handler = {
|
|
429
|
+
"browser_open": self._step_browser_open,
|
|
430
|
+
"delay": self._step_delay,
|
|
431
|
+
"shell": self._step_shell,
|
|
432
|
+
"browser_extract": self._step_browser_extract,
|
|
433
|
+
"browser_screenshot": self._step_browser_screenshot,
|
|
434
|
+
"send_email": self._step_send_email,
|
|
435
|
+
"api_call": self._step_api_call,
|
|
436
|
+
"condition": self._step_condition,
|
|
437
|
+
}.get(step_type)
|
|
438
|
+
|
|
439
|
+
if handler is None:
|
|
440
|
+
return {
|
|
441
|
+
"status": "failed",
|
|
442
|
+
"result": None,
|
|
443
|
+
"error": f"未知步骤类型: {step_type}",
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
result = handler(params, context)
|
|
447
|
+
return {"status": "success", "result": result, "error": None}
|
|
448
|
+
|
|
449
|
+
except Exception as exc:
|
|
450
|
+
logger.error(
|
|
451
|
+
"步骤 #%d [%s] 执行失败: %s",
|
|
452
|
+
step.get("id", "?"),
|
|
453
|
+
step_type,
|
|
454
|
+
exc,
|
|
455
|
+
)
|
|
456
|
+
return {
|
|
457
|
+
"status": "failed",
|
|
458
|
+
"result": None,
|
|
459
|
+
"error": truncate_str(str(exc), 2000),
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# --- 步骤实现 ---
|
|
463
|
+
|
|
464
|
+
def _step_browser_open(self, params: Dict, context: Dict) -> str:
|
|
465
|
+
"""browser_open: 使用 stealth 浏览器打开 URL。"""
|
|
466
|
+
url = params.get("url", "")
|
|
467
|
+
profile = params.get("profile", "")
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
from aiskills.browser_stealth import open_stealth_browser
|
|
471
|
+
|
|
472
|
+
page = open_stealth_browser(url=url, profile=profile or None)
|
|
473
|
+
if page:
|
|
474
|
+
context["browser_page"] = page
|
|
475
|
+
return f"浏览器已打开: {url}"
|
|
476
|
+
except ImportError:
|
|
477
|
+
logger.warning("aiskills.browser_stealth 不可用,模拟打开")
|
|
478
|
+
return f"[模拟] 浏览器已打开: {url}"
|
|
479
|
+
except Exception as exc:
|
|
480
|
+
raise RuntimeError(f"浏览器启动失败: {exc}") from exc
|
|
481
|
+
|
|
482
|
+
def _step_delay(self, params: Dict, context: Dict) -> str:
|
|
483
|
+
"""delay: 等待指定秒数。"""
|
|
484
|
+
seconds = float(params.get("seconds", 1))
|
|
485
|
+
seconds = max(0.1, min(seconds, 300)) # 限制 0.1 ~ 300 秒
|
|
486
|
+
time.sleep(seconds)
|
|
487
|
+
return f"等待 {seconds} 秒完成"
|
|
488
|
+
|
|
489
|
+
def _step_shell(self, params: Dict, context: Dict) -> str:
|
|
490
|
+
"""shell: 执行 Shell 命令。"""
|
|
491
|
+
command = params.get("command", "")
|
|
492
|
+
if not command:
|
|
493
|
+
raise ValueError("Shell 命令不能为空")
|
|
494
|
+
|
|
495
|
+
timeout = int(params.get("timeout", 120))
|
|
496
|
+
timeout = max(1, min(timeout, 600)) # 限制 1 ~ 600 秒
|
|
497
|
+
|
|
498
|
+
logger.info("执行 Shell 命令: %s", command)
|
|
499
|
+
proc = subprocess.run(
|
|
500
|
+
command,
|
|
501
|
+
shell=True,
|
|
502
|
+
capture_output=True,
|
|
503
|
+
text=True,
|
|
504
|
+
timeout=timeout,
|
|
505
|
+
encoding="utf-8",
|
|
506
|
+
errors="replace",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
output_parts = []
|
|
510
|
+
if proc.stdout:
|
|
511
|
+
output_parts.append(proc.stdout.strip())
|
|
512
|
+
if proc.stderr:
|
|
513
|
+
output_parts.append(f"[stderr] {proc.stderr.strip()}")
|
|
514
|
+
if proc.returncode != 0:
|
|
515
|
+
output_parts.append(f"[exit code: {proc.returncode}]")
|
|
516
|
+
|
|
517
|
+
result = "\n".join(output_parts) if output_parts else "(无输出)"
|
|
518
|
+
if proc.returncode != 0:
|
|
519
|
+
raise RuntimeError(f"命令执行失败 (exit {proc.returncode}): {result}")
|
|
520
|
+
return result
|
|
521
|
+
|
|
522
|
+
def _step_browser_extract(self, params: Dict, context: Dict) -> str:
|
|
523
|
+
"""browser_extract: 从浏览器页面提取内容。"""
|
|
524
|
+
selector = params.get("selector", "")
|
|
525
|
+
attribute = params.get("attribute", "text")
|
|
526
|
+
|
|
527
|
+
page = context.get("browser_page")
|
|
528
|
+
if page is None:
|
|
529
|
+
raise RuntimeError(
|
|
530
|
+
"浏览器页面未打开,请先执行 browser_open 步骤"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
from DrissionPage import ChromiumPage
|
|
535
|
+
|
|
536
|
+
if not isinstance(page, ChromiumPage):
|
|
537
|
+
raise RuntimeError("当前浏览器对象类型不支持提取操作")
|
|
538
|
+
|
|
539
|
+
elements = page.eles(selector)
|
|
540
|
+
if not elements:
|
|
541
|
+
return "(未找到匹配元素)"
|
|
542
|
+
|
|
543
|
+
results = []
|
|
544
|
+
for el in elements:
|
|
545
|
+
if attribute == "text":
|
|
546
|
+
results.append(el.text)
|
|
547
|
+
else:
|
|
548
|
+
val = el.attr(attribute)
|
|
549
|
+
if val:
|
|
550
|
+
results.append(str(val))
|
|
551
|
+
|
|
552
|
+
extracted = "\n".join(results)
|
|
553
|
+
logger.info(
|
|
554
|
+
"提取到 %d 条元素: %s", len(results), truncate_str(extracted, 200)
|
|
555
|
+
)
|
|
556
|
+
return truncate_str(extracted, 50000)
|
|
557
|
+
|
|
558
|
+
except ImportError:
|
|
559
|
+
raise RuntimeError("DrissionPage 未安装,无法执行提取操作")
|
|
560
|
+
except Exception as exc:
|
|
561
|
+
raise RuntimeError(f"页面提取失败: {exc}") from exc
|
|
562
|
+
|
|
563
|
+
def _step_browser_screenshot(self, params: Dict, context: Dict) -> str:
|
|
564
|
+
"""browser_screenshot: 截取浏览器页面截图。"""
|
|
565
|
+
save_path = params.get("save_path", "")
|
|
566
|
+
if not save_path:
|
|
567
|
+
raise ValueError("save_path 不能为空")
|
|
568
|
+
|
|
569
|
+
page = context.get("browser_page")
|
|
570
|
+
if page is None:
|
|
571
|
+
raise RuntimeError(
|
|
572
|
+
"浏览器页面未打开,请先执行 browser_open 步骤"
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
# 确保父目录存在
|
|
577
|
+
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
|
|
578
|
+
page.get_screenshot(path=save_path, full_page=True)
|
|
579
|
+
return f"截图已保存: {save_path}"
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
raise RuntimeError(f"截图失败: {exc}") from exc
|
|
582
|
+
|
|
583
|
+
def _step_send_email(self, params: Dict, context: Dict) -> str:
|
|
584
|
+
"""send_email: 发送邮件(当前为占位实现,记录日志并返回)。"""
|
|
585
|
+
to = params.get("to", "")
|
|
586
|
+
subject = params.get("subject", "")
|
|
587
|
+
body = params.get("body", "")
|
|
588
|
+
|
|
589
|
+
if not to:
|
|
590
|
+
raise ValueError("收件人 (to) 不能为空")
|
|
591
|
+
|
|
592
|
+
# 占位实现:记录日志,后续接入真实 SMTP / API
|
|
593
|
+
logger.info(
|
|
594
|
+
"发送邮件 → %s | 主题: %s | 正文: %s",
|
|
595
|
+
to,
|
|
596
|
+
subject,
|
|
597
|
+
truncate_str(body, 200),
|
|
598
|
+
)
|
|
599
|
+
return f"邮件已发送至 {to},主题: {subject}"
|
|
600
|
+
|
|
601
|
+
def _step_api_call(self, params: Dict, context: Dict) -> str:
|
|
602
|
+
"""api_call: 调用 HTTP API。"""
|
|
603
|
+
url = params.get("url", "")
|
|
604
|
+
method = (params.get("method") or "GET").upper()
|
|
605
|
+
headers_raw = params.get("headers", "{}")
|
|
606
|
+
body = params.get("body", "")
|
|
607
|
+
|
|
608
|
+
if not url:
|
|
609
|
+
raise ValueError("API URL 不能为空")
|
|
610
|
+
|
|
611
|
+
# 解析 headers
|
|
612
|
+
try:
|
|
613
|
+
headers = (
|
|
614
|
+
json.loads(headers_raw)
|
|
615
|
+
if isinstance(headers_raw, str)
|
|
616
|
+
else headers_raw
|
|
617
|
+
)
|
|
618
|
+
except json.JSONDecodeError as exc:
|
|
619
|
+
raise ValueError(f"Headers JSON 解析失败: {exc}") from exc
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
import requests # type: ignore
|
|
623
|
+
except ImportError:
|
|
624
|
+
raise RuntimeError("requests 库未安装,无法执行 API 调用")
|
|
625
|
+
|
|
626
|
+
logger.info("API 调用 %s %s", method, url)
|
|
627
|
+
try:
|
|
628
|
+
resp = requests.request(
|
|
629
|
+
method=method,
|
|
630
|
+
url=url,
|
|
631
|
+
headers=headers,
|
|
632
|
+
data=body if body else None,
|
|
633
|
+
timeout=60,
|
|
634
|
+
)
|
|
635
|
+
result = f"HTTP {resp.status_code}\n{resp.text}"
|
|
636
|
+
resp.raise_for_status()
|
|
637
|
+
return truncate_str(result, 50000)
|
|
638
|
+
except requests.exceptions.HTTPError as exc:
|
|
639
|
+
return truncate_str(f"HTTP 错误: {exc}\n{resp.text}", 50000)
|
|
640
|
+
except requests.exceptions.RequestException as exc:
|
|
641
|
+
raise RuntimeError(f"API 调用失败: {exc}") from exc
|
|
642
|
+
|
|
643
|
+
def _step_condition(self, params: Dict, context: Dict) -> str:
|
|
644
|
+
"""condition: 评估条件表达式,决定继续或停止。
|
|
645
|
+
|
|
646
|
+
返回特殊标记 ``__CONTINUE__`` 或 ``__STOP__`` 由上层解析。
|
|
647
|
+
"""
|
|
648
|
+
expression = params.get("expression", "")
|
|
649
|
+
on_true = params.get("on_true", "next")
|
|
650
|
+
on_false = params.get("on_false", "stop")
|
|
651
|
+
|
|
652
|
+
if not expression:
|
|
653
|
+
raise ValueError("条件表达式不能为空")
|
|
654
|
+
|
|
655
|
+
# 构建求值上下文
|
|
656
|
+
eval_context: Dict[str, Any] = {
|
|
657
|
+
"steps": context.get("steps_results", {}),
|
|
658
|
+
"variables": context.get("variables", {}),
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
result = _safe_eval(expression, eval_context)
|
|
663
|
+
except (ValueError, TypeError, SyntaxError) as exc:
|
|
664
|
+
raise ValueError(f"条件求值失败: {exc}") from exc
|
|
665
|
+
|
|
666
|
+
if result:
|
|
667
|
+
action = on_true
|
|
668
|
+
logger.info("条件 [%s] = True → %s", expression, action)
|
|
669
|
+
else:
|
|
670
|
+
action = on_false
|
|
671
|
+
logger.info("条件 [%s] = False → %s", expression, action)
|
|
672
|
+
|
|
673
|
+
# 返回动作标记
|
|
674
|
+
if action == "stop":
|
|
675
|
+
return "__STOP__"
|
|
676
|
+
return f"条件满足 (={result}),继续执行"
|
|
677
|
+
|
|
678
|
+
# ------------------------------------------------------------------
|
|
679
|
+
# 工作流执行
|
|
680
|
+
# ------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
async def run_workflow(
|
|
683
|
+
self,
|
|
684
|
+
agent_path: str,
|
|
685
|
+
workflow_id: str,
|
|
686
|
+
variables: Dict | None = None,
|
|
687
|
+
) -> Dict:
|
|
688
|
+
"""按顺序执行工作流中的所有步骤,记录结果。
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
agent_path: Agent 路径
|
|
692
|
+
workflow_id: 工作流 ID
|
|
693
|
+
variables: 运行时覆盖变量(合并到工作流变量中)
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
执行记录 dict
|
|
697
|
+
"""
|
|
698
|
+
workflow = self.get_workflow(agent_path, workflow_id)
|
|
699
|
+
if workflow is None:
|
|
700
|
+
raise FileNotFoundError(
|
|
701
|
+
f"工作流不存在: {agent_path}/{workflow_id}"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
steps = workflow.get("steps", [])
|
|
705
|
+
if not steps:
|
|
706
|
+
logger.warning("工作流 [%s] 没有步骤", workflow_id)
|
|
707
|
+
|
|
708
|
+
# 合并变量:运行时变量覆盖工作流变量
|
|
709
|
+
merged_vars = dict(workflow.get("variables", {}))
|
|
710
|
+
if variables:
|
|
711
|
+
merged_vars.update(variables)
|
|
712
|
+
|
|
713
|
+
run_id = f"run_{int(time.time())}_{generate_id()[:6]}"
|
|
714
|
+
now = timestamp()
|
|
715
|
+
|
|
716
|
+
run_record: Dict[str, Any] = {
|
|
717
|
+
"run_id": run_id,
|
|
718
|
+
"workflow_id": workflow_id,
|
|
719
|
+
"agent_path": agent_path,
|
|
720
|
+
"status": "running",
|
|
721
|
+
"started_at": now,
|
|
722
|
+
"finished_at": None,
|
|
723
|
+
"steps_log": [],
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
# 构建执行上下文
|
|
727
|
+
exec_context: Dict[str, Any] = {
|
|
728
|
+
"agent_path": agent_path,
|
|
729
|
+
"variables": merged_vars,
|
|
730
|
+
"steps_results": {},
|
|
731
|
+
"browser_page": None,
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
logger.info(
|
|
735
|
+
"========== 工作流开始 [%s] %s (%d 步) ==========",
|
|
736
|
+
run_id,
|
|
737
|
+
workflow.get("name", ""),
|
|
738
|
+
len(steps),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
should_stop = False
|
|
742
|
+
overall_status = "completed"
|
|
743
|
+
|
|
744
|
+
for step in steps:
|
|
745
|
+
step_id = step.get("id", 0)
|
|
746
|
+
step_name = step.get("name", "")
|
|
747
|
+
step_type = step.get("type", "")
|
|
748
|
+
step_started = timestamp()
|
|
749
|
+
|
|
750
|
+
if should_stop:
|
|
751
|
+
step_log = {
|
|
752
|
+
"step_id": step_id,
|
|
753
|
+
"name": step_name,
|
|
754
|
+
"type": step_type,
|
|
755
|
+
"status": "skipped",
|
|
756
|
+
"started_at": step_started,
|
|
757
|
+
"finished_at": timestamp(),
|
|
758
|
+
"result": None,
|
|
759
|
+
"error": "前置条件不满足,跳过",
|
|
760
|
+
}
|
|
761
|
+
run_record["steps_log"].append(step_log)
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
logger.info(
|
|
765
|
+
" → 步骤 #%d [%s] %s ...", step_id, step_type, step_name
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# 执行步骤
|
|
769
|
+
result = self._execute_step(step, exec_context)
|
|
770
|
+
step_finished = timestamp()
|
|
771
|
+
|
|
772
|
+
step_status = result["status"]
|
|
773
|
+
step_result = result.get("result")
|
|
774
|
+
step_error = result.get("error")
|
|
775
|
+
|
|
776
|
+
step_log = {
|
|
777
|
+
"step_id": step_id,
|
|
778
|
+
"name": step_name,
|
|
779
|
+
"type": step_type,
|
|
780
|
+
"status": step_status,
|
|
781
|
+
"started_at": step_started,
|
|
782
|
+
"finished_at": step_finished,
|
|
783
|
+
"result": truncate_str(str(step_result), 50000)
|
|
784
|
+
if step_result is not None
|
|
785
|
+
else None,
|
|
786
|
+
"error": step_error,
|
|
787
|
+
}
|
|
788
|
+
run_record["steps_log"].append(step_log)
|
|
789
|
+
|
|
790
|
+
# 存储结果供后续步骤引用
|
|
791
|
+
exec_context["steps_results"][step_id] = {
|
|
792
|
+
"result": step_result,
|
|
793
|
+
"error": step_error,
|
|
794
|
+
"status": step_status,
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
# 检查是否需要停止
|
|
798
|
+
if step_status == "failed":
|
|
799
|
+
logger.error(
|
|
800
|
+
" ✗ 步骤 #%d 失败: %s", step_id, step_error
|
|
801
|
+
)
|
|
802
|
+
overall_status = "failed"
|
|
803
|
+
should_stop = True
|
|
804
|
+
elif (
|
|
805
|
+
step_status == "success"
|
|
806
|
+
and isinstance(step_result, str)
|
|
807
|
+
and step_result == "__STOP__"
|
|
808
|
+
):
|
|
809
|
+
logger.info(" ■ 步骤 #%d 触发停止", step_id)
|
|
810
|
+
overall_status = "stopped"
|
|
811
|
+
should_stop = True
|
|
812
|
+
else:
|
|
813
|
+
logger.info(
|
|
814
|
+
" ✓ 步骤 #%d 完成", step_id
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
# 清理浏览器
|
|
818
|
+
if exec_context.get("browser_page"):
|
|
819
|
+
try:
|
|
820
|
+
exec_context["browser_page"].quit()
|
|
821
|
+
except Exception:
|
|
822
|
+
pass
|
|
823
|
+
|
|
824
|
+
# 写入运行记录
|
|
825
|
+
run_record["finished_at"] = timestamp()
|
|
826
|
+
run_record["status"] = overall_status
|
|
827
|
+
run_file = self._run_file(agent_path, run_id)
|
|
828
|
+
|
|
829
|
+
with self._lock:
|
|
830
|
+
self._write_json(run_file, run_record)
|
|
831
|
+
|
|
832
|
+
# 更新工作流统计
|
|
833
|
+
with self._lock:
|
|
834
|
+
wf_path = self._workflow_file(agent_path, workflow_id)
|
|
835
|
+
wf = self._read_json(wf_path)
|
|
836
|
+
if wf:
|
|
837
|
+
wf["last_run"] = run_record["started_at"]
|
|
838
|
+
wf["last_status"] = overall_status
|
|
839
|
+
wf["run_count"] = wf.get("run_count", 0) + 1
|
|
840
|
+
wf["updated_at"] = timestamp()
|
|
841
|
+
self._write_json(wf_path, wf)
|
|
842
|
+
|
|
843
|
+
logger.info(
|
|
844
|
+
"========== 工作流结束 [%s] 状态: %s ==========",
|
|
845
|
+
run_id,
|
|
846
|
+
overall_status,
|
|
847
|
+
)
|
|
848
|
+
return run_record
|
|
849
|
+
|
|
850
|
+
# ------------------------------------------------------------------
|
|
851
|
+
# 运行记录查询
|
|
852
|
+
# ------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
def list_runs(
|
|
855
|
+
self,
|
|
856
|
+
agent_path: str,
|
|
857
|
+
workflow_id: str | None = None,
|
|
858
|
+
limit: int = 20,
|
|
859
|
+
) -> List[Dict]:
|
|
860
|
+
"""列出执行运行记录,按时间倒序。"""
|
|
861
|
+
runs_dir = self._runs_dir(agent_path)
|
|
862
|
+
if not runs_dir.is_dir():
|
|
863
|
+
return []
|
|
864
|
+
|
|
865
|
+
records: List[Dict] = []
|
|
866
|
+
pattern = (
|
|
867
|
+
f"{workflow_id}_*.json" if workflow_id else "run_*.json"
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
for fp in sorted(runs_dir.glob(pattern), reverse=True):
|
|
871
|
+
rec = self._read_json(fp)
|
|
872
|
+
if rec:
|
|
873
|
+
records.append(rec)
|
|
874
|
+
if len(records) >= limit:
|
|
875
|
+
break
|
|
876
|
+
|
|
877
|
+
return records
|
|
878
|
+
|
|
879
|
+
def get_run(
|
|
880
|
+
self, agent_path: str, run_id: str
|
|
881
|
+
) -> Optional[Dict]:
|
|
882
|
+
"""获取单条运行记录。"""
|
|
883
|
+
path = self._run_file(agent_path, run_id)
|
|
884
|
+
return self._read_json(path)
|
|
885
|
+
|
|
886
|
+
def delete_run(self, agent_path: str, run_id: str) -> bool:
|
|
887
|
+
"""删除单条运行记录。"""
|
|
888
|
+
path = self._run_file(agent_path, run_id)
|
|
889
|
+
with self._lock:
|
|
890
|
+
if not path.exists():
|
|
891
|
+
return False
|
|
892
|
+
path.unlink()
|
|
893
|
+
return True
|
|
894
|
+
|
|
895
|
+
def cleanup_old_runs(
|
|
896
|
+
self,
|
|
897
|
+
agent_path: str,
|
|
898
|
+
workflow_id: str | None = None,
|
|
899
|
+
keep_count: int = 50,
|
|
900
|
+
) -> int:
|
|
901
|
+
"""清理旧运行记录,仅保留最近 N 条。"""
|
|
902
|
+
runs = self.list_runs(
|
|
903
|
+
agent_path, workflow_id, limit=99999
|
|
904
|
+
)
|
|
905
|
+
if len(runs) <= keep_count:
|
|
906
|
+
return 0
|
|
907
|
+
|
|
908
|
+
removed = 0
|
|
909
|
+
for rec in runs[keep_count:]:
|
|
910
|
+
if self.delete_run(agent_path, rec["run_id"]):
|
|
911
|
+
removed += 1
|
|
912
|
+
|
|
913
|
+
if removed:
|
|
914
|
+
logger.info(
|
|
915
|
+
"清理了 %d 条旧运行记录 (agent=%s, keep=%d)",
|
|
916
|
+
removed,
|
|
917
|
+
agent_path,
|
|
918
|
+
keep_count,
|
|
919
|
+
)
|
|
920
|
+
return removed
|
|
921
|
+
|
|
922
|
+
# ------------------------------------------------------------------
|
|
923
|
+
# 工作流启用/禁用
|
|
924
|
+
# ------------------------------------------------------------------
|
|
925
|
+
|
|
926
|
+
def toggle_workflow(
|
|
927
|
+
self, agent_path: str, workflow_id: str, enabled: bool | None = None
|
|
928
|
+
) -> Dict:
|
|
929
|
+
"""切换工作流启用/禁用状态。若 enabled 为 None 则自动取反。"""
|
|
930
|
+
workflow = self.get_workflow(agent_path, workflow_id)
|
|
931
|
+
if workflow is None:
|
|
932
|
+
raise FileNotFoundError(
|
|
933
|
+
f"工作流不存在: {agent_path}/{workflow_id}"
|
|
934
|
+
)
|
|
935
|
+
if enabled is None:
|
|
936
|
+
enabled = not workflow.get("enabled", True)
|
|
937
|
+
return self.update_workflow(
|
|
938
|
+
agent_path, workflow_id, {"enabled": enabled}
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------
|
|
942
|
+
# 工作流排序
|
|
943
|
+
# ------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
def reorder_steps(
|
|
946
|
+
self,
|
|
947
|
+
agent_path: str,
|
|
948
|
+
workflow_id: str,
|
|
949
|
+
step_ids: List[int],
|
|
950
|
+
) -> Dict:
|
|
951
|
+
"""重新排列工作流步骤顺序。"""
|
|
952
|
+
workflow = self.get_workflow(agent_path, workflow_id)
|
|
953
|
+
if workflow is None:
|
|
954
|
+
raise FileNotFoundError(
|
|
955
|
+
f"工作流不存在: {agent_path}/{workflow_id}"
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
existing_steps = {s["id"]: s for s in workflow.get("steps", [])}
|
|
959
|
+
reordered = []
|
|
960
|
+
for sid in step_ids:
|
|
961
|
+
if sid in existing_steps:
|
|
962
|
+
reordered.append(existing_steps[sid])
|
|
963
|
+
|
|
964
|
+
# 附加不在新顺序中的步骤(安全兜底)
|
|
965
|
+
existing_ids = set(reordered)
|
|
966
|
+
for s in workflow.get("steps", []):
|
|
967
|
+
if s["id"] not in existing_ids:
|
|
968
|
+
reordered.append(s)
|
|
969
|
+
|
|
970
|
+
return self.update_workflow(
|
|
971
|
+
agent_path, workflow_id, {"steps": reordered}
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
# ------------------------------------------------------------------
|
|
975
|
+
# 统计信息
|
|
976
|
+
# ------------------------------------------------------------------
|
|
977
|
+
|
|
978
|
+
def get_stats(self, agent_path: str) -> Dict[str, Any]:
|
|
979
|
+
"""获取 Agent 的工作流统计信息。"""
|
|
980
|
+
workflows = self.list_workflows(agent_path)
|
|
981
|
+
total_runs = 0
|
|
982
|
+
success_runs = 0
|
|
983
|
+
failed_runs = 0
|
|
984
|
+
|
|
985
|
+
for wf in workflows:
|
|
986
|
+
rc = wf.get("run_count", 0)
|
|
987
|
+
total_runs += rc
|
|
988
|
+
if wf.get("last_status") == "completed":
|
|
989
|
+
success_runs += 1
|
|
990
|
+
elif wf.get("last_status") == "failed":
|
|
991
|
+
failed_runs += 1
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
"agent_path": agent_path,
|
|
995
|
+
"total_workflows": len(workflows),
|
|
996
|
+
"enabled_workflows": sum(
|
|
997
|
+
1 for wf in workflows if wf.get("enabled", True)
|
|
998
|
+
),
|
|
999
|
+
"total_runs": total_runs,
|
|
1000
|
+
"success_runs": success_runs,
|
|
1001
|
+
"failed_runs": failed_runs,
|
|
1002
|
+
}
|