codebee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""自动化(定时任务):按计划到点自动拉起一次编排运行,无人值守推进日常工作。
|
|
3
|
+
|
|
4
|
+
数据落盘 <data>/automation.json(tmp + os.replace 原子写;data 目录由 paths.py
|
|
5
|
+
决定,TUTTI_DATA 环境变量感知)。调度是一个 daemon 线程,每 TICK_SECONDS 秒
|
|
6
|
+
扫一遍到期任务:到点即复用与 /api/tasks 完全相同的链路(store.create_task →
|
|
7
|
+
store.create_run → jobs.enqueue)拉起一次真实运行,不自己造运行器。单次触发
|
|
8
|
+
失败只把 last_status 记为 error 并推进 next_run,调度线程绝不允许因异常退出。
|
|
9
|
+
|
|
10
|
+
任务模型:
|
|
11
|
+
{id, name, prompt, workdir, kind: daily|interval|weekly|once, time: "HH:MM",
|
|
12
|
+
interval_hours, weekday(0-6 周一=0), run_at(once 用, ISO), flow(编排流程 id),
|
|
13
|
+
enabled, created_at, last_run, next_run, run_count, last_status}
|
|
14
|
+
|
|
15
|
+
重启语义:错过的 once 不补跑(启动恢复时直接停用);daily/weekly/interval
|
|
16
|
+
重算 next_run 到下一个未来时刻即可,不追赶停机期间错过的周期。
|
|
17
|
+
|
|
18
|
+
主进程接线(main.py,服务启动时调用一次):
|
|
19
|
+
from core import automation
|
|
20
|
+
n = automation.start()
|
|
21
|
+
HTTP 分支契约见仓库集成说明:GET/POST /api/automation、
|
|
22
|
+
GET/POST /api/automation/<id>、POST /api/automation/<id>/(toggle|run|delete)。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import secrets
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime, timedelta
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from . import jobs, paths, store
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
_LOCK = threading.RLock()
|
|
41
|
+
_FILE = paths.DATA_DIR / "automation.json"
|
|
42
|
+
_TASKS = {} # id → task dict(内存真源;落盘为 {"version":1,"tasks":[全部任务]})
|
|
43
|
+
_LOADED = False
|
|
44
|
+
_STARTED = False
|
|
45
|
+
|
|
46
|
+
TICK_SECONDS = 25 # 调度扫描间隔:到点触发误差不超过半个周期
|
|
47
|
+
KINDS = ("daily", "interval", "weekly", "once")
|
|
48
|
+
INTERVAL_MIN, INTERVAL_MAX = 1, 720 # interval_hours 合法区间(小时)
|
|
49
|
+
DEFAULT_FLOW = "doc" # 未指定编排流程时的兜底:review 引擎产出文档,适配巡检/整理类提示词
|
|
50
|
+
|
|
51
|
+
_TIME_RE = re.compile(r"\s*(\d{1,2}):(\d{1,2})\s*")
|
|
52
|
+
|
|
53
|
+
# 字段缺省(加载历史文件/脏数据时兜底;enabled 兜底为 False,绝不意外触发)
|
|
54
|
+
_DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "time": "",
|
|
55
|
+
"interval_hours": 0, "weekday": -1, "run_at": "", "flow": "",
|
|
56
|
+
"enabled": False, "created_at": "", "last_run": "", "next_run": "",
|
|
57
|
+
"run_count": 0, "last_status": ""}
|
|
58
|
+
|
|
59
|
+
# 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
|
|
60
|
+
_UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
|
|
61
|
+
"weekday", "run_at", "flow", "enabled")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------- 时间工具
|
|
65
|
+
|
|
66
|
+
def _parse_dt(s):
|
|
67
|
+
"""宽松解析 ISO/常用时间串(%Y-%m-%d %H:%M[:S]、带 T 的 ISO 均可)。失败返回 None。"""
|
|
68
|
+
try:
|
|
69
|
+
return datetime.fromisoformat(str(s or "").strip())
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fmt_dt(dt):
|
|
75
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _norm_hhmm(v):
|
|
79
|
+
"""归一 "HH:MM"(24 小时制,容忍 9:5 这类输入)。非法返回 None。"""
|
|
80
|
+
m = _TIME_RE.match(str(v or ""))
|
|
81
|
+
if not m:
|
|
82
|
+
return None
|
|
83
|
+
h, mi = int(m.group(1)), int(m.group(2))
|
|
84
|
+
if not (0 <= h <= 23 and 0 <= mi <= 59):
|
|
85
|
+
return None
|
|
86
|
+
return "%02d:%02d" % (h, mi)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_next_run(t, now=None):
|
|
90
|
+
"""按任务的 kind 计算下一次运行时间(本地时间串)。算不出返回空串(tick 跳过)。
|
|
91
|
+
|
|
92
|
+
daily = 每天 HH:MM(已过今天点 → 明天,正确跨天)
|
|
93
|
+
weekly = 指定星期几的 HH:MM(已过本周点 → 下周,正确跨周)
|
|
94
|
+
interval= 每 N 小时:锚点取 last_run(无则 created_at),锚点早已过去时按 N
|
|
95
|
+
小时整步推进到第一个未来时刻(停机不追赶、不突发补跑)
|
|
96
|
+
once = 即 run_at 本身(错过的由启动恢复停用,正常路径到点触发后即停用)
|
|
97
|
+
"""
|
|
98
|
+
now = now or datetime.now()
|
|
99
|
+
kind = t.get("kind")
|
|
100
|
+
if kind == "daily":
|
|
101
|
+
hhmm = _norm_hhmm(t.get("time"))
|
|
102
|
+
if hhmm is None:
|
|
103
|
+
return ""
|
|
104
|
+
h, mi = hhmm.split(":")
|
|
105
|
+
cand = now.replace(hour=int(h), minute=int(mi), second=0, microsecond=0)
|
|
106
|
+
if cand <= now:
|
|
107
|
+
cand += timedelta(days=1)
|
|
108
|
+
return _fmt_dt(cand)
|
|
109
|
+
if kind == "weekly":
|
|
110
|
+
hhmm = _norm_hhmm(t.get("time"))
|
|
111
|
+
try:
|
|
112
|
+
wd = int(t.get("weekday"))
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
return ""
|
|
115
|
+
if hhmm is None or not 0 <= wd <= 6:
|
|
116
|
+
return ""
|
|
117
|
+
h, mi = hhmm.split(":")
|
|
118
|
+
cand = now.replace(hour=int(h), minute=int(mi), second=0, microsecond=0)
|
|
119
|
+
cand += timedelta(days=(wd - now.weekday()) % 7)
|
|
120
|
+
if cand <= now:
|
|
121
|
+
cand += timedelta(days=7)
|
|
122
|
+
return _fmt_dt(cand)
|
|
123
|
+
if kind == "interval":
|
|
124
|
+
try:
|
|
125
|
+
n = max(INTERVAL_MIN, int(t.get("interval_hours")))
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
return ""
|
|
128
|
+
anchor = _parse_dt(t.get("last_run")) or _parse_dt(t.get("created_at")) or now
|
|
129
|
+
if anchor > now:
|
|
130
|
+
return _fmt_dt(anchor)
|
|
131
|
+
k = int((now - anchor).total_seconds() // (n * 3600)) + 1
|
|
132
|
+
return _fmt_dt(anchor + timedelta(hours=n * k))
|
|
133
|
+
if kind == "once":
|
|
134
|
+
dt = _parse_dt(t.get("run_at"))
|
|
135
|
+
return _fmt_dt(dt) if dt else ""
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------- 校验
|
|
140
|
+
|
|
141
|
+
def _validate_core(t, check_workdir=True):
|
|
142
|
+
"""name/prompt/workdir 校验与归一(就地改写 t)。workdir 为空 = 跟随「默认
|
|
143
|
+
保存路径」(与 store.create_task 口径一致,到点触发时由它兜底创建)。"""
|
|
144
|
+
name = str(t.get("name") or "").strip()
|
|
145
|
+
if not name:
|
|
146
|
+
raise ValueError("任务名称不能为空")
|
|
147
|
+
t["name"] = name[:60]
|
|
148
|
+
prompt = str(t.get("prompt") or "").strip()
|
|
149
|
+
if not prompt:
|
|
150
|
+
raise ValueError("执行提示词(prompt)不能为空")
|
|
151
|
+
t["prompt"] = prompt
|
|
152
|
+
workdir = str(t.get("workdir") or "").strip()
|
|
153
|
+
if workdir:
|
|
154
|
+
p = Path(workdir).expanduser()
|
|
155
|
+
if not p.is_absolute():
|
|
156
|
+
raise ValueError("工作目录必须是绝对路径")
|
|
157
|
+
if check_workdir and not p.is_dir():
|
|
158
|
+
raise ValueError("工作目录不存在: %s" % workdir)
|
|
159
|
+
t["workdir"] = str(p)
|
|
160
|
+
else:
|
|
161
|
+
t["workdir"] = ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _apply_schedule(t, check_flow=True):
|
|
165
|
+
"""按 kind 校验并归一调度字段(就地改写 t)。缺字段/越界/once 时间已过均抛
|
|
166
|
+
ValueError——错过的一次性任务不补跑,所以创建/修改时直接拒绝过去时间。"""
|
|
167
|
+
kind = t.get("kind")
|
|
168
|
+
if kind not in KINDS:
|
|
169
|
+
raise ValueError("kind 必须是 %s 之一" % "、".join(KINDS))
|
|
170
|
+
if kind in ("daily", "weekly"):
|
|
171
|
+
hhmm = _norm_hhmm(t.get("time"))
|
|
172
|
+
if hhmm is None:
|
|
173
|
+
raise ValueError("time 必须是 24 小时制 HH:MM")
|
|
174
|
+
t["time"] = hhmm
|
|
175
|
+
if kind == "weekly":
|
|
176
|
+
try:
|
|
177
|
+
wd = int(t.get("weekday"))
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
raise ValueError("weekday 必须是 0-6 的整数(0=周一)")
|
|
180
|
+
if not 0 <= wd <= 6:
|
|
181
|
+
raise ValueError("weekday 必须是 0-6 的整数(0=周一)")
|
|
182
|
+
t["weekday"] = wd
|
|
183
|
+
if kind == "interval":
|
|
184
|
+
try:
|
|
185
|
+
n = int(t.get("interval_hours"))
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
raise ValueError("interval_hours 必须是 %d-%d 的整数" % (INTERVAL_MIN, INTERVAL_MAX))
|
|
188
|
+
if not INTERVAL_MIN <= n <= INTERVAL_MAX:
|
|
189
|
+
raise ValueError("interval_hours 必须是 %d-%d 的整数" % (INTERVAL_MIN, INTERVAL_MAX))
|
|
190
|
+
t["interval_hours"] = n
|
|
191
|
+
if kind == "once":
|
|
192
|
+
dt = _parse_dt(t.get("run_at"))
|
|
193
|
+
if dt is None:
|
|
194
|
+
raise ValueError("once 任务需要 run_at(ISO 时间,如 2026-09-20T09:30)")
|
|
195
|
+
if dt <= datetime.now():
|
|
196
|
+
raise ValueError("once 的 run_at 必须是未来时间(错过的一次性任务不补跑)")
|
|
197
|
+
t["run_at"] = _fmt_dt(dt)
|
|
198
|
+
flow = str(t.get("flow") or "").strip()
|
|
199
|
+
if flow:
|
|
200
|
+
if check_flow:
|
|
201
|
+
from . import flows as flows_mod
|
|
202
|
+
if flows_mod.get_flow(flow) is None:
|
|
203
|
+
raise ValueError("未知编排流程:%s(可选见 /api/flows)" % flow)
|
|
204
|
+
t["flow"] = flow
|
|
205
|
+
else:
|
|
206
|
+
t["flow"] = DEFAULT_FLOW
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------- 持久化
|
|
210
|
+
|
|
211
|
+
def _list_locked():
|
|
212
|
+
return [_TASKS[i] for i in sorted(_TASKS.keys())]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _save_locked():
|
|
216
|
+
"""整体落盘:tmp + os.replace 原子替换,坏一半也不会丢旧数据。"""
|
|
217
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
tmp = _FILE.with_suffix(".tmp")
|
|
219
|
+
tmp.write_text(json.dumps({"version": 1, "tasks": _list_locked()},
|
|
220
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
221
|
+
os.replace(str(tmp), str(_FILE))
|
|
222
|
+
store.bump_state() # 落盘即状态变化:多端 SSE 尽快看到
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _normalize(d):
|
|
226
|
+
"""把磁盘上的历史条目收敛到当前模型(脏字段兜底,绝不抛异常)。"""
|
|
227
|
+
t = dict(_DEFAULTS)
|
|
228
|
+
if isinstance(d, dict):
|
|
229
|
+
t.update({k: v for k, v in d.items() if k in _DEFAULTS})
|
|
230
|
+
if t["kind"] not in KINDS:
|
|
231
|
+
t["kind"] = ""
|
|
232
|
+
try:
|
|
233
|
+
t["run_count"] = max(0, int(t["run_count"]))
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
t["run_count"] = 0
|
|
236
|
+
t["enabled"] = bool(t["enabled"])
|
|
237
|
+
return t
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def load(force=False):
|
|
241
|
+
"""从磁盘加载任务(幂等)。返回任务数。测试可重绑 _FILE 后 force=True。"""
|
|
242
|
+
global _LOADED
|
|
243
|
+
with _LOCK:
|
|
244
|
+
if _LOADED and not force:
|
|
245
|
+
return len(_TASKS)
|
|
246
|
+
_TASKS.clear()
|
|
247
|
+
try:
|
|
248
|
+
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
249
|
+
except Exception:
|
|
250
|
+
data = {}
|
|
251
|
+
raw = data.get("tasks") if isinstance(data, dict) else data
|
|
252
|
+
for d in raw or []:
|
|
253
|
+
t = _normalize(d)
|
|
254
|
+
if t["id"]:
|
|
255
|
+
_TASKS[t["id"]] = t
|
|
256
|
+
_LOADED = True
|
|
257
|
+
return len(_TASKS)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _ensure_loaded():
|
|
261
|
+
if not _LOADED:
|
|
262
|
+
load()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------- 启动恢复
|
|
266
|
+
|
|
267
|
+
def _recover_after_restart():
|
|
268
|
+
"""服务重启后的调度对账:错过的 once 直接停用(不补跑);其余 kind 重算
|
|
269
|
+
next_run 到下一个未来时刻。必须在 load() 之后调用。返回改动条数。"""
|
|
270
|
+
now = datetime.now()
|
|
271
|
+
changed = 0
|
|
272
|
+
with _LOCK:
|
|
273
|
+
for t in _TASKS.values():
|
|
274
|
+
if not t.get("enabled"):
|
|
275
|
+
continue
|
|
276
|
+
if t.get("kind") == "once":
|
|
277
|
+
nr = _parse_dt(t.get("next_run") or "")
|
|
278
|
+
if nr is None or nr <= now:
|
|
279
|
+
t["enabled"] = False
|
|
280
|
+
t["next_run"] = ""
|
|
281
|
+
t["last_status"] = "missed"
|
|
282
|
+
changed += 1
|
|
283
|
+
else:
|
|
284
|
+
t["next_run"] = compute_next_run(t, now=now)
|
|
285
|
+
changed += 1
|
|
286
|
+
if changed:
|
|
287
|
+
_save_locked()
|
|
288
|
+
return changed
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------- 运行拉起
|
|
292
|
+
|
|
293
|
+
def _launch_run(t):
|
|
294
|
+
"""拉起一次真实编排运行:与 main.py 的 /api/tasks 走同一条链路
|
|
295
|
+
(store.create_task → store.create_run → jobs 入队),返回 run_id。
|
|
296
|
+
测试可把本函数打成 stub,绝不真调 LLM/CLI。"""
|
|
297
|
+
payload = {"type": t.get("flow") or DEFAULT_FLOW,
|
|
298
|
+
"title": ("%s %s" % (t.get("name") or "定时任务",
|
|
299
|
+
time.strftime("%m-%d %H:%M"))).strip()[:40],
|
|
300
|
+
"goal": t.get("prompt") or "",
|
|
301
|
+
"workdir": (t.get("workdir") or "").strip()}
|
|
302
|
+
task = store.create_task(payload)
|
|
303
|
+
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
304
|
+
store.update_task_status(task["id"], "queued")
|
|
305
|
+
jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
|
|
306
|
+
return run["id"]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _fire(snapshot, now):
|
|
310
|
+
"""触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
|
|
311
|
+
once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
|
|
312
|
+
status = "queued"
|
|
313
|
+
try:
|
|
314
|
+
_launch_run(snapshot)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
|
|
317
|
+
status = "error"
|
|
318
|
+
with _LOCK:
|
|
319
|
+
cur = _TASKS.get(snapshot.get("id"))
|
|
320
|
+
if cur is None:
|
|
321
|
+
return
|
|
322
|
+
cur["last_run"] = _fmt_dt(now)
|
|
323
|
+
cur["run_count"] = int(cur.get("run_count") or 0) + 1
|
|
324
|
+
cur["last_status"] = status
|
|
325
|
+
if cur.get("kind") == "once":
|
|
326
|
+
cur["enabled"] = False
|
|
327
|
+
cur["next_run"] = ""
|
|
328
|
+
else:
|
|
329
|
+
cur["next_run"] = compute_next_run(cur, now=now)
|
|
330
|
+
_save_locked()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------- 调度线程
|
|
334
|
+
|
|
335
|
+
def _tick():
|
|
336
|
+
"""扫描一遍到期任务并逐个触发。单任务异常只跳过该条,绝不上抛。"""
|
|
337
|
+
now = datetime.now()
|
|
338
|
+
due = []
|
|
339
|
+
with _LOCK:
|
|
340
|
+
for tid in sorted(_TASKS.keys()):
|
|
341
|
+
t = _TASKS[tid]
|
|
342
|
+
if not t.get("enabled"):
|
|
343
|
+
continue
|
|
344
|
+
nr = _parse_dt(t.get("next_run") or "")
|
|
345
|
+
if nr is not None and nr <= now:
|
|
346
|
+
due.append(tid)
|
|
347
|
+
for tid in due:
|
|
348
|
+
try:
|
|
349
|
+
with _LOCK:
|
|
350
|
+
t = _TASKS.get(tid)
|
|
351
|
+
if t is None or not t.get("enabled"):
|
|
352
|
+
continue # 等待期间被删/被停:跳过
|
|
353
|
+
_fire(dict(t), now)
|
|
354
|
+
except Exception:
|
|
355
|
+
log.exception("automation: tick 处理任务 %s 异常,已跳过", tid)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _loop():
|
|
359
|
+
while True:
|
|
360
|
+
try:
|
|
361
|
+
_tick()
|
|
362
|
+
except Exception:
|
|
363
|
+
log.exception("automation: 调度 tick 异常,继续下一轮")
|
|
364
|
+
time.sleep(TICK_SECONDS)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def start():
|
|
368
|
+
"""主进程启动时调用一次:加载任务、做重启对账并拉起调度线程。返回任务数。
|
|
369
|
+
重复调用是安全的(幂等,不会起第二条线程)。"""
|
|
370
|
+
global _STARTED
|
|
371
|
+
with _LOCK:
|
|
372
|
+
if _STARTED:
|
|
373
|
+
return len(_TASKS)
|
|
374
|
+
_STARTED = True
|
|
375
|
+
n = load()
|
|
376
|
+
_recover_after_restart()
|
|
377
|
+
try:
|
|
378
|
+
threading.Thread(target=_loop, name="automation-scheduler",
|
|
379
|
+
daemon=True).start()
|
|
380
|
+
except Exception:
|
|
381
|
+
log.exception("automation: 调度线程启动失败(定时任务本轮不会触发)")
|
|
382
|
+
return n
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------- CRUD + 控制
|
|
386
|
+
|
|
387
|
+
def list_tasks():
|
|
388
|
+
"""全部定时任务(新在前)。返回副本,调用方随便改。"""
|
|
389
|
+
_ensure_loaded()
|
|
390
|
+
with _LOCK:
|
|
391
|
+
return [dict(_TASKS[i]) for i in sorted(_TASKS.keys(), reverse=True)]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def get_task(tid):
|
|
395
|
+
_ensure_loaded()
|
|
396
|
+
with _LOCK:
|
|
397
|
+
t = _TASKS.get(tid)
|
|
398
|
+
return dict(t) if t else None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def create(payload):
|
|
402
|
+
"""创建定时任务(payload 为请求体 dict)。校验失败抛 ValueError。"""
|
|
403
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
404
|
+
_ensure_loaded()
|
|
405
|
+
t = dict(_DEFAULTS)
|
|
406
|
+
t.update({k: payload.get(k) for k in _UPDATABLE})
|
|
407
|
+
t["id"] = "auto-%s-%04d" % (time.strftime("%Y%m%d-%H%M%S"), secrets.randbelow(10000))
|
|
408
|
+
t["enabled"] = bool(payload.get("enabled", True))
|
|
409
|
+
t["created_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
410
|
+
t["kind"] = payload.get("kind") or "daily"
|
|
411
|
+
_validate_core(t)
|
|
412
|
+
_apply_schedule(t)
|
|
413
|
+
t["next_run"] = compute_next_run(t)
|
|
414
|
+
with _LOCK:
|
|
415
|
+
_TASKS[t["id"]] = t
|
|
416
|
+
_save_locked()
|
|
417
|
+
return dict(t)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def update(tid, patch):
|
|
421
|
+
"""部分更新(patch 只影响出现的字段)。调度字段变动后重算 next_run。
|
|
422
|
+
任务不存在返回 None;校验失败抛 ValueError(不落盘、不改内存)。"""
|
|
423
|
+
_ensure_loaded()
|
|
424
|
+
patch = patch if isinstance(patch, dict) else {}
|
|
425
|
+
with _LOCK:
|
|
426
|
+
cur = _TASKS.get(tid)
|
|
427
|
+
if cur is None:
|
|
428
|
+
return None
|
|
429
|
+
merged = dict(cur)
|
|
430
|
+
for k in _UPDATABLE:
|
|
431
|
+
if k in patch:
|
|
432
|
+
merged[k] = patch[k]
|
|
433
|
+
_validate_core(merged, check_workdir=("workdir" in patch))
|
|
434
|
+
_apply_schedule(merged, check_flow=("flow" in patch))
|
|
435
|
+
merged["next_run"] = compute_next_run(merged)
|
|
436
|
+
_TASKS[tid] = merged
|
|
437
|
+
_save_locked()
|
|
438
|
+
return dict(merged)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def set_enabled(tid, enabled):
|
|
442
|
+
"""启用/停用。启用时重算 next_run;once 启用即成过去式则按「错过」停用。"""
|
|
443
|
+
_ensure_loaded()
|
|
444
|
+
with _LOCK:
|
|
445
|
+
t = _TASKS.get(tid)
|
|
446
|
+
if t is None:
|
|
447
|
+
return None
|
|
448
|
+
t["enabled"] = bool(enabled)
|
|
449
|
+
t["next_run"] = compute_next_run(t) if t["enabled"] else ""
|
|
450
|
+
if t["enabled"] and t.get("kind") == "once":
|
|
451
|
+
nr = _parse_dt(t.get("next_run") or "")
|
|
452
|
+
if nr is None or nr <= datetime.now():
|
|
453
|
+
t["enabled"] = False
|
|
454
|
+
t["next_run"] = ""
|
|
455
|
+
t["last_status"] = "missed"
|
|
456
|
+
_save_locked()
|
|
457
|
+
return dict(t)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def run_now(tid):
|
|
461
|
+
"""立即执行一次:不影响 next_run(到点仍照常触发)。返回 (task, run_id);
|
|
462
|
+
任务不存在返回 (None, None);拉起失败 last_status=error、run_id 为空串。"""
|
|
463
|
+
_ensure_loaded()
|
|
464
|
+
with _LOCK:
|
|
465
|
+
exists = tid in _TASKS
|
|
466
|
+
if not exists:
|
|
467
|
+
return None, None
|
|
468
|
+
now = datetime.now()
|
|
469
|
+
run_id = ""
|
|
470
|
+
status = "queued"
|
|
471
|
+
try:
|
|
472
|
+
with _LOCK:
|
|
473
|
+
snapshot = dict(_TASKS[tid])
|
|
474
|
+
run_id = _launch_run(snapshot)
|
|
475
|
+
except Exception as e:
|
|
476
|
+
log.warning("automation: 手动触发 %s 失败: %r", tid, e)
|
|
477
|
+
status = "error"
|
|
478
|
+
with _LOCK:
|
|
479
|
+
t = _TASKS.get(tid)
|
|
480
|
+
if t is None:
|
|
481
|
+
return None, None
|
|
482
|
+
t["last_run"] = _fmt_dt(now)
|
|
483
|
+
t["run_count"] = int(t.get("run_count") or 0) + 1
|
|
484
|
+
t["last_status"] = status
|
|
485
|
+
_save_locked()
|
|
486
|
+
return dict(t), run_id
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def delete(tid):
|
|
490
|
+
"""删除任务(运行产生的任务/运行记录不受影响,仍在任务列表里可查)。"""
|
|
491
|
+
_ensure_loaded()
|
|
492
|
+
with _LOCK:
|
|
493
|
+
if tid not in _TASKS:
|
|
494
|
+
return False
|
|
495
|
+
del _TASKS[tid]
|
|
496
|
+
_save_locked()
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---------------------------------------------------------------- 内置模板
|
|
501
|
+
|
|
502
|
+
TEMPLATES = [
|
|
503
|
+
{
|
|
504
|
+
"id": "daily-repo-inspection",
|
|
505
|
+
"name": "每日仓库巡检",
|
|
506
|
+
"desc": "每天早上自动巡检一次仓库:汇总未提交变更、梳理 TODO/FIXME 待办与风险,"
|
|
507
|
+
"生成当天的巡检报告 INSPECTION.md。",
|
|
508
|
+
"prompt": "对本工作目录的代码仓库做一次巡检,只读分析,不要修改任何源码文件:\n"
|
|
509
|
+
"1. 运行 git status 与 git diff --stat,汇总当前未提交的变更;"
|
|
510
|
+
"再用 git log --oneline -15 概览最近的提交脉络。\n"
|
|
511
|
+
"2. 扫描源码里的 TODO、FIXME、HACK 标记,列出仍然悬挂的条目,"
|
|
512
|
+
"标注所在文件与行号。\n"
|
|
513
|
+
"3. 检查是否有临时产物混进源码(调试脚本、遗留的 print/console 调试输出、"
|
|
514
|
+
"没进忽略规则的生成文件)。\n"
|
|
515
|
+
"4. 输出一份 markdown 巡检报告保存为 INSPECTION.md,分三节:"
|
|
516
|
+
"「变更概览」「待办与风险」「今日建议」,每节不超过 10 条,直接给结论。",
|
|
517
|
+
"suggested_kind": "daily",
|
|
518
|
+
"suggested_time": "09:00",
|
|
519
|
+
"suggested_flow": "research",
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
"id": "serial-novel-advance",
|
|
523
|
+
"name": "连载定时推进",
|
|
524
|
+
"desc": "配合连载小说任务:到点自动读取故事圣经与已有章节,续写下一段剧情,"
|
|
525
|
+
"并自检人设、时间线与伏笔的一致性。",
|
|
526
|
+
"prompt": "继续推进本工作目录里的连载小说:\n"
|
|
527
|
+
"1. 先读故事圣经(若有)与全部已有章节,回顾主线剧情、人物设定与未回收的伏笔,"
|
|
528
|
+
"用 3 到 5 句话确认「接下来该发生什么」再动笔。\n"
|
|
529
|
+
"2. 按既有单章字数与文风续写下一章:承接收尾章节的节奏,"
|
|
530
|
+
"至少推进一条主线冲突,人物言行必须与圣经设定一致。\n"
|
|
531
|
+
"3. 写完后自检:人称与时态是否统一、伏笔是否被误收、与前文章节有无矛盾,"
|
|
532
|
+
"发现问题立即修订。\n"
|
|
533
|
+
"4. 成稿按已有章节的命名规则保存,章末附一句话「下一章预告」。"
|
|
534
|
+
"不要改动已完成章节的任何内容。",
|
|
535
|
+
"suggested_kind": "interval",
|
|
536
|
+
"suggested_time": "",
|
|
537
|
+
"suggested_interval_hours": 12,
|
|
538
|
+
"suggested_flow": "novel",
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
"id": "weekly-experience-cleanup",
|
|
542
|
+
"name": "经验库周整理",
|
|
543
|
+
"desc": "每周一自动整理经验库:合并重复教训、按主题归纳、标记疑似过时的条目,"
|
|
544
|
+
"产出精简整理报告(不直接改写原文件)。",
|
|
545
|
+
"prompt": "整理本机 CodeBee 经验库(数据目录下 skills.json 的教训条目,"
|
|
546
|
+
"含 id/title/content/category 等字段),只读分析原文件,把整理结果写成报告:\n"
|
|
547
|
+
"1. 通读全部教训条目,找出语义重复或高度相似的条目,"
|
|
548
|
+
"给出合并建议(保留哪条、并入哪些、合并后的表述)。\n"
|
|
549
|
+
"2. 按「环境安装、编排流程、提示词技巧、踩坑预警」等主题归纳分组,"
|
|
550
|
+
"指出每组里最经得起复用的 2 到 3 条。\n"
|
|
551
|
+
"3. 标记疑似过时的条目(依赖已升级、场景已下线)并说明理由。\n"
|
|
552
|
+
"4. 输出 markdown 报告保存为 EXPERIENCE-REVIEW.md:先给汇总数字"
|
|
553
|
+
"(总数、重复组数、疑似过时数),再给分组明细与合并建议清单。"
|
|
554
|
+
"不要直接改写 skills.json,合并动作等人工确认。",
|
|
555
|
+
"suggested_kind": "weekly",
|
|
556
|
+
"suggested_time": "09:30",
|
|
557
|
+
"suggested_weekday": 0,
|
|
558
|
+
"suggested_flow": "doc",
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
"id": "weekly-dependency-security-scan",
|
|
562
|
+
"name": "依赖与安全扫描",
|
|
563
|
+
"desc": "每周五自动对项目做一次只读的依赖与安全巡检:清点直接依赖、"
|
|
564
|
+
"排查敏感文件与明文密钥风险,给出升级整改建议。",
|
|
565
|
+
"prompt": "对本工作目录的项目做一次只读的依赖与安全巡检,"
|
|
566
|
+
"不要安装、升级或修改任何文件:\n"
|
|
567
|
+
"1. 识别项目类型与依赖清单(package.json、requirements.txt、"
|
|
568
|
+
"pyproject.toml、go.mod 等),列出直接依赖及声明版本。\n"
|
|
569
|
+
"2. 排查敏感信息风险:.env、密钥文件、含明文 token 的配置是否被 git 跟踪"
|
|
570
|
+
"(用 git ls-files 核对);源码中硬编码的 password、token、secret 字样。\n"
|
|
571
|
+
"3. 检查依赖健康度:明显落后的大版本、已停止维护的包"
|
|
572
|
+
"(凭依赖名与版本判断,不确定就标注「待人工确认」)。\n"
|
|
573
|
+
"4. 输出 markdown 报告保存为 SECURITY-REVIEW.md,分「依赖清单」"
|
|
574
|
+
"「风险发现(按严重程度排序)」「升级与整改建议」三节;只报告,不执行修复。",
|
|
575
|
+
"suggested_kind": "weekly",
|
|
576
|
+
"suggested_time": "18:00",
|
|
577
|
+
"suggested_weekday": 4,
|
|
578
|
+
"suggested_flow": "research",
|
|
579
|
+
},
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def templates():
|
|
584
|
+
"""内置模板(供前端渲染卡片)。返回副本。"""
|
|
585
|
+
return [dict(t) for t in TEMPLATES]
|