feat(任务): 任务级「公共巡检」——独立于步骤画布的守护条件(含 webhook 通知)

需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。

配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
  到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
  互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。

- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
  检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
  动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
  不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
  步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
  用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
  给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。

顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
  非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
  而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。

修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。

自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
This commit is contained in:
2026-09-16 13:02:40 +08:00
parent 243df3071b
commit c29516cff5
15 changed files with 644 additions and 41 deletions
+18 -7
View File
@@ -310,10 +310,15 @@ _LEVEL_BY_HINT = (
)
def _level_of(event_key):
for hints, lv in _LEVEL_BY_HINT:
def _level_of(event_key, fields=None):
"""消息级别:**事件自己带的 `level` 优先**(步骤「发通知」/巡检都能自己选),
否则按事件 key 猜(failed→error、success→success…),猜不到算 info。"""
lv = (fields or {}).get("level")
if lv in ("info", "success", "warning", "error"):
return lv
for hints, lv2 in _LEVEL_BY_HINT:
if any(h in event_key for h in hints):
return lv
return lv2
return "info"
@@ -331,6 +336,8 @@ _FIELD_LABELS = {
"timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号",
"apk_name": "应用", "apk_id": "应用ID", "package_name": "包名",
"success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数",
"title": "标题", "message": "内容", "patrol_name": "巡检",
"check_label": "检查", "action_label": "动作", "detail": "结果",
"stopped": "停止", "failed_devices": "失败设备",
"failed_items": "失败明细", "filename": "文件", "size": "大小(字节)",
"tables": "表数", "include_apk": "含APK", "user": "操作人", "operator": "操作人",
@@ -350,7 +357,9 @@ def _label(k):
def _subject(ev, fields):
"""标题主体:优先任务名,其次设备名/serial。"""
"""标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。"""
if fields.get("title"):
return str(fields["title"])
for k in ("job_name", "apk_name", "hook_name"):
if fields.get(k):
return str(fields[k])
@@ -365,9 +374,11 @@ def build_message(ev, fields, hook=None):
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
fields = dict(fields or {})
merged = fields.pop("_merged", None)
level = _level_of(ev.key)
level = _level_of(ev.key, fields)
subj = _subject(ev, fields)
title = ev.label + (f":{subj}" if subj else "")
# 「发通知」步骤自带标题时**直接用它**:用户写的是"抖音号1 掉线了",
# 前面再挂一个"任务自定义通知:"纯属噪音(其它事件照旧带事件名)
title = str(fields["title"]) if fields.get("title") else ev.label + (f":{subj}" if subj else "")
if merged and merged.get("merged_count", 0) > 1:
title = f"{ev.label}:{subj}" if subj else ev.label
title += f"({merged['merged_count']} 次)"
@@ -382,7 +393,7 @@ def build_message(ev, fields, hook=None):
continue
# 标题里已经写出来的主体(任务名/设备名)不再重复占一行:
# "任务批次结束:抖音养号" 下面再来一行 "任务:抖音养号" 是纯噪音
if k in ("job_name", "device_name", "apk_name", "hook_name") \
if k in ("job_name", "device_name", "apk_name", "hook_name", "title") \
and str(v) == str(subj):
continue
# 设备名优先:两个都带时只显示名字——IP 是给日志看的,通知里没人想读地址