codebee 0.1.62 → 0.1.63

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +499 -490
  2. package/LICENSE +21 -21
  3. package/README.md +484 -486
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/aiflavor.py +197 -197
  12. package/app/core/attachments.py +532 -532
  13. package/app/core/automation.py +758 -758
  14. package/app/core/backup.py +470 -470
  15. package/app/core/beekeeper.py +183 -183
  16. package/app/core/board.py +209 -209
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1191 -1191
  20. package/app/core/capability.py +144 -144
  21. package/app/core/catalog.py +19 -14
  22. package/app/core/chaptersafe.py +32 -32
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +186 -186
  25. package/app/core/covergen.py +356 -356
  26. package/app/core/diagnostics.py +114 -114
  27. package/app/core/dispatch.py +173 -173
  28. package/app/core/dispatch_log.py +113 -113
  29. package/app/core/env_scrub.py +85 -85
  30. package/app/core/error_codes.py +64 -64
  31. package/app/core/errorlog.py +137 -137
  32. package/app/core/flows.py +355 -355
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +341 -341
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +156 -156
  37. package/app/core/jobs.py +1022 -1022
  38. package/app/core/manager.py +1996 -1996
  39. package/app/core/market.py +362 -362
  40. package/app/core/market_remote.py +66 -66
  41. package/app/core/mocks.py +64 -64
  42. package/app/core/modelhub.py +3266 -3266
  43. package/app/core/notify.py +106 -106
  44. package/app/core/paihang.py +109 -109
  45. package/app/core/paths.py +2 -2
  46. package/app/core/pipeline.py +4444 -4426
  47. package/app/core/planner.py +27 -27
  48. package/app/core/portguard.py +111 -111
  49. package/app/core/preview.py +293 -293
  50. package/app/core/publish/__init__.py +7 -7
  51. package/app/core/publish/auto.py +367 -367
  52. package/app/core/publish/browser.py +533 -533
  53. package/app/core/publish/fanqie.py +128 -128
  54. package/app/core/publish/flow.py +406 -406
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +590 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +124 -124
  61. package/app/core/router.py +20 -3
  62. package/app/core/runner.py +2014 -1991
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +106 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/step_runner.py +132 -132
  78. package/app/core/task_compile.py +199 -199
  79. package/app/core/telemetry.py +292 -292
  80. package/app/core/tlsctx.py +64 -64
  81. package/app/core/token_meter.py +147 -147
  82. package/app/core/volumes.py +355 -355
  83. package/app/core/wxdigest.py +858 -858
  84. package/app/core/zentao.py +2090 -2090
  85. package/app/main.py +2826 -2826
  86. package/app/pick_dialog.py +110 -110
  87. package/app/ui/app.js +13977 -13977
  88. package/app/ui/board.html +162 -162
  89. package/app/ui/board.js +305 -305
  90. package/app/ui/i18n.js +2711 -2711
  91. package/app/ui/icons/bee.svg +79 -79
  92. package/app/ui/index.html +1 -1
  93. package/app/ui/manifest.json +16 -16
  94. package/app/ui/qrcode.js +2297 -2297
  95. package/app/ui/style.css +6125 -6125
  96. package/bin/tutti.js +147 -147
  97. package/package.json +41 -41
  98. package/tools/wx_reader.py +261 -261
@@ -1,758 +1,758 @@
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
- mode(编排模式), thinking(思考程度), direct_provider_id/direct_model(仅 direct 流程),
14
- enabled, created_at, last_run, next_run, run_count, last_status}
15
-
16
- 运行偏好(mode/thinking/对话模型)与新建任务 Composer 的三颗胶囊同一套取值,
17
- 到点拉起时原样透传给 store.create_task——定时任务不是另一条执行链,用户在这里
18
- 选的编排强度与模型偏好必须与手动建任务完全等价。
19
-
20
- 重启语义:错过的 once 不补跑(启动恢复时直接停用);daily/weekly/interval
21
- 重算 next_run 到下一个未来时刻即可,不追赶停机期间错过的周期。
22
-
23
- 主进程接线(main.py,服务启动时调用一次):
24
- from core import automation
25
- n = automation.start()
26
- HTTP 分支契约见仓库集成说明:GET/POST /api/automation、
27
- GET/POST /api/automation/<id>、POST /api/automation/<id>/(toggle|run|delete)。
28
- """
29
- from __future__ import annotations
30
-
31
- import json
32
- import logging
33
- import os
34
- import re
35
- import secrets
36
- import threading
37
- import time
38
- from datetime import datetime, timedelta
39
- from pathlib import Path
40
-
41
- from . import jobs, paths, store
42
-
43
- log = logging.getLogger(__name__)
44
-
45
- _LOCK = threading.RLock()
46
- _FILE = paths.DATA_DIR / "automation.json"
47
- _TASKS = {} # id → task dict(内存真源;落盘为 {"version":1,"tasks":[全部任务]})
48
- _LOADED = False
49
- _STARTED = False
50
-
51
- TICK_SECONDS = 25 # 调度扫描间隔:到点触发误差不超过半个周期
52
- KINDS = ("daily", "interval", "weekly", "once")
53
- INTERVAL_MIN, INTERVAL_MAX = 1, 720 # interval_hours 合法区间(小时)
54
- DEFAULT_FLOW = "doc" # 未指定编排流程时的兜底:review 引擎产出文档,适配巡检/整理类提示词
55
-
56
- # 运行偏好取值(必须与 store.create_task 的口径一致,否则到点拉起时被静默改档)
57
- MODES = ("auto", "fast", "expert", "manual")
58
- THINKINGS = ("auto", "low", "standard", "high")
59
-
60
- _TIME_RE = re.compile(r"\s*(\d{1,2}):(\d{1,2})\s*")
61
-
62
- # 字段缺省(加载历史文件/脏数据时兜底;enabled 兜底为 False,绝不意外触发)
63
- _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "time": "",
64
- "interval_hours": 0, "weekday": -1, "run_at": "", "flow": "",
65
- "mode": "auto", "thinking": "standard",
66
- "direct_provider_id": "", "direct_model": "",
67
- "enabled": False, "created_at": "", "last_run": "", "next_run": "",
68
- "run_count": 0, "last_status": "", "last_run_id": ""}
69
-
70
- # 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
71
- _UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
72
- "weekday", "run_at", "flow", "mode", "thinking",
73
- "direct_provider_id", "direct_model", "enabled")
74
-
75
-
76
- # ---------------------------------------------------------------- 时间工具
77
-
78
- def _parse_dt(s):
79
- """宽松解析 ISO/常用时间串(%Y-%m-%d %H:%M[:S]、带 T 的 ISO 均可)。失败返回 None。"""
80
- try:
81
- return datetime.fromisoformat(str(s or "").strip())
82
- except ValueError:
83
- return None
84
-
85
-
86
- def _fmt_dt(dt):
87
- return dt.strftime("%Y-%m-%d %H:%M:%S")
88
-
89
-
90
- def _norm_hhmm(v):
91
- """归一 "HH:MM"(24 小时制,容忍 9:5 这类输入)。非法返回 None。"""
92
- m = _TIME_RE.match(str(v or ""))
93
- if not m:
94
- return None
95
- h, mi = int(m.group(1)), int(m.group(2))
96
- if not (0 <= h <= 23 and 0 <= mi <= 59):
97
- return None
98
- return "%02d:%02d" % (h, mi)
99
-
100
-
101
- def compute_next_run(t, now=None):
102
- """按任务的 kind 计算下一次运行时间(本地时间串)。算不出返回空串(tick 跳过)。
103
-
104
- daily = 每天 HH:MM(已过今天点 → 明天,正确跨天)
105
- weekly = 指定星期几的 HH:MM(已过本周点 → 下周,正确跨周)
106
- interval= 每 N 小时:锚点取 last_run(无则 created_at),锚点早已过去时按 N
107
- 小时整步推进到第一个未来时刻(停机不追赶、不突发补跑)
108
- once = 即 run_at 本身(错过的由启动恢复停用,正常路径到点触发后即停用)
109
- """
110
- now = now or datetime.now()
111
- kind = t.get("kind")
112
- if kind == "daily":
113
- hhmm = _norm_hhmm(t.get("time"))
114
- if hhmm is None:
115
- return ""
116
- h, mi = hhmm.split(":")
117
- cand = now.replace(hour=int(h), minute=int(mi), second=0, microsecond=0)
118
- if cand <= now:
119
- cand += timedelta(days=1)
120
- return _fmt_dt(cand)
121
- if kind == "weekly":
122
- hhmm = _norm_hhmm(t.get("time"))
123
- try:
124
- wd = int(t.get("weekday"))
125
- except (TypeError, ValueError):
126
- return ""
127
- if hhmm is None or not 0 <= wd <= 6:
128
- return ""
129
- h, mi = hhmm.split(":")
130
- cand = now.replace(hour=int(h), minute=int(mi), second=0, microsecond=0)
131
- cand += timedelta(days=(wd - now.weekday()) % 7)
132
- if cand <= now:
133
- cand += timedelta(days=7)
134
- return _fmt_dt(cand)
135
- if kind == "interval":
136
- try:
137
- n = max(INTERVAL_MIN, int(t.get("interval_hours")))
138
- except (TypeError, ValueError):
139
- return ""
140
- anchor = _parse_dt(t.get("last_run")) or _parse_dt(t.get("created_at")) or now
141
- if anchor > now:
142
- return _fmt_dt(anchor)
143
- k = int((now - anchor).total_seconds() // (n * 3600)) + 1
144
- return _fmt_dt(anchor + timedelta(hours=n * k))
145
- if kind == "once":
146
- dt = _parse_dt(t.get("run_at"))
147
- return _fmt_dt(dt) if dt else ""
148
- return ""
149
-
150
-
151
- # ---------------------------------------------------------------- 校验
152
-
153
- def _validate_core(t, check_workdir=True):
154
- """name/prompt/workdir 校验与归一(就地改写 t)。workdir 为空 = 跟随「默认
155
- 保存路径」(与 store.create_task 口径一致,到点触发时由它兜底创建)。"""
156
- name = str(t.get("name") or "").strip()
157
- if not name:
158
- raise ValueError("任务名称不能为空")
159
- t["name"] = name[:60]
160
- prompt = str(t.get("prompt") or "").strip()
161
- if not prompt:
162
- raise ValueError("执行提示词(prompt)不能为空")
163
- t["prompt"] = prompt
164
- workdir = str(t.get("workdir") or "").strip()
165
- if workdir:
166
- p = Path(workdir).expanduser()
167
- if not p.is_absolute():
168
- raise ValueError("工作目录必须是绝对路径")
169
- if check_workdir and not p.is_dir():
170
- raise ValueError("工作目录不存在: %s" % workdir)
171
- t["workdir"] = str(p)
172
- else:
173
- t["workdir"] = ""
174
-
175
-
176
- def _apply_schedule(t, check_flow=True):
177
- """按 kind 校验并归一调度字段(就地改写 t)。缺字段/越界/once 时间已过均抛
178
- ValueError——错过的一次性任务不补跑,所以创建/修改时直接拒绝过去时间。"""
179
- kind = t.get("kind")
180
- if kind not in KINDS:
181
- raise ValueError("kind 必须是 %s 之一" % "、".join(KINDS))
182
- if kind in ("daily", "weekly"):
183
- hhmm = _norm_hhmm(t.get("time"))
184
- if hhmm is None:
185
- raise ValueError("time 必须是 24 小时制 HH:MM")
186
- t["time"] = hhmm
187
- if kind == "weekly":
188
- try:
189
- wd = int(t.get("weekday"))
190
- except (TypeError, ValueError):
191
- raise ValueError("weekday 必须是 0-6 的整数(0=周一)")
192
- if not 0 <= wd <= 6:
193
- raise ValueError("weekday 必须是 0-6 的整数(0=周一)")
194
- t["weekday"] = wd
195
- if kind == "interval":
196
- try:
197
- n = int(t.get("interval_hours"))
198
- except (TypeError, ValueError):
199
- raise ValueError("interval_hours 必须是 %d-%d 的整数" % (INTERVAL_MIN, INTERVAL_MAX))
200
- if not INTERVAL_MIN <= n <= INTERVAL_MAX:
201
- raise ValueError("interval_hours 必须是 %d-%d 的整数" % (INTERVAL_MIN, INTERVAL_MAX))
202
- t["interval_hours"] = n
203
- if kind == "once":
204
- dt = _parse_dt(t.get("run_at"))
205
- if dt is None:
206
- raise ValueError("once 任务需要 run_at(ISO 时间,如 2026-09-20T09:30)")
207
- if dt <= datetime.now():
208
- raise ValueError("once 的 run_at 必须是未来时间(错过的一次性任务不补跑)")
209
- t["run_at"] = _fmt_dt(dt)
210
- flow = str(t.get("flow") or "").strip()
211
- if flow:
212
- if check_flow:
213
- from . import flows as flows_mod
214
- if flows_mod.get_flow(flow) is None:
215
- raise ValueError("未知编排流程:%s(可选见 /api/flows)" % flow)
216
- t["flow"] = flow
217
- else:
218
- t["flow"] = DEFAULT_FLOW
219
-
220
-
221
- def _flow_engine(flow_id):
222
- """流程引擎名(查不到返回空串)。"""
223
- from . import flows as flows_mod
224
- f = flows_mod.get_flow(str(flow_id or "").strip())
225
- return (f or {}).get("engine") or ""
226
-
227
-
228
- def _apply_run_prefs(t):
229
- """校验并归一运行偏好(编排模式/思考程度/对话模型),就地改写 t。必须在
230
- _apply_schedule 之后调用——只有那里才把 flow 归一成最终值。
231
-
232
- mode/thinking 非法值一律收敛到默认(auto/standard),不抛异常:这两个是
233
- 「偏好」不是「配置」,脏值不该把保存卡死。对话模型只在流程引擎为 direct
234
- 时有意义:非 direct 流程一律清空,避免换流程后残留的模型绑定在下一次
235
- 触发时静默生效(用户看不到却在生效的配置,比没有更糟)。direct 流程下
236
- 「有模型没厂商」则明确报错——猜一个厂商等于把模型请求打到别家。
237
- """
238
- mode = str(t.get("mode") or "").strip().lower()
239
- t["mode"] = mode if mode in MODES else "auto"
240
- thinking = str(t.get("thinking") or "").strip().lower()
241
- t["thinking"] = thinking if thinking in THINKINGS else "standard"
242
- pid = str(t.get("direct_provider_id") or "").strip()[:80]
243
- model = str(t.get("direct_model") or "").strip()[:160]
244
- if _flow_engine(t.get("flow")) != "direct":
245
- t["direct_provider_id"], t["direct_model"] = "", ""
246
- return
247
- if model and not pid:
248
- raise ValueError("指定对话模型时必须同时选择厂商")
249
- t["direct_provider_id"], t["direct_model"] = pid, model
250
-
251
-
252
- # ---------------------------------------------------------------- 持久化
253
-
254
- def _list_locked():
255
- return [_TASKS[i] for i in sorted(_TASKS.keys())]
256
-
257
-
258
- def _save_locked():
259
- """整体落盘:tmp + os.replace 原子替换,坏一半也不会丢旧数据。"""
260
- _FILE.parent.mkdir(parents=True, exist_ok=True)
261
- tmp = _FILE.with_suffix(".tmp")
262
- tmp.write_text(json.dumps({"version": 1, "tasks": _list_locked()},
263
- ensure_ascii=False, indent=2), encoding="utf-8")
264
- os.replace(str(tmp), str(_FILE))
265
- store.bump_state() # 落盘即状态变化:多端 SSE 尽快看到
266
-
267
-
268
- def _normalize(d):
269
- """把磁盘上的历史条目收敛到当前模型(脏字段兜底,绝不抛异常)。"""
270
- t = dict(_DEFAULTS)
271
- if isinstance(d, dict):
272
- t.update({k: v for k, v in d.items() if k in _DEFAULTS})
273
- if t["kind"] not in KINDS:
274
- t["kind"] = ""
275
- try:
276
- t["run_count"] = max(0, int(t["run_count"]))
277
- except (TypeError, ValueError):
278
- t["run_count"] = 0
279
- t["enabled"] = bool(t["enabled"])
280
- return t
281
-
282
-
283
- def load(force=False):
284
- """从磁盘加载任务(幂等)。返回任务数。测试可重绑 _FILE 后 force=True。"""
285
- global _LOADED
286
- with _LOCK:
287
- if _LOADED and not force:
288
- return len(_TASKS)
289
- _TASKS.clear()
290
- try:
291
- data = json.loads(_FILE.read_text(encoding="utf-8"))
292
- except Exception:
293
- data = {}
294
- raw = data.get("tasks") if isinstance(data, dict) else data
295
- for d in raw or []:
296
- t = _normalize(d)
297
- if t["id"]:
298
- _TASKS[t["id"]] = t
299
- _LOADED = True
300
- return len(_TASKS)
301
-
302
-
303
- def _ensure_loaded():
304
- if not _LOADED:
305
- load()
306
-
307
-
308
- # ---------------------------------------------------------------- 启动恢复
309
-
310
- def _recover_after_restart():
311
- """服务重启后的调度对账:错过的 once 直接停用(不补跑);其余 kind 重算
312
- next_run 到下一个未来时刻。必须在 load() 之后调用。返回改动条数。"""
313
- now = datetime.now()
314
- changed = 0
315
- with _LOCK:
316
- for t in _TASKS.values():
317
- if not t.get("enabled"):
318
- continue
319
- if t.get("kind") == "once":
320
- nr = _parse_dt(t.get("next_run") or "")
321
- if nr is None or nr <= now:
322
- t["enabled"] = False
323
- t["next_run"] = ""
324
- t["last_status"] = "missed"
325
- changed += 1
326
- else:
327
- t["next_run"] = compute_next_run(t, now=now)
328
- changed += 1
329
- if changed:
330
- _save_locked()
331
- return changed
332
-
333
-
334
- # ---------------------------------------------------------------- 运行拉起
335
-
336
- def _launch_run(t):
337
- """拉起一次真实编排运行:与 main.py 的 /api/tasks 走同一条链路
338
- (store.create_task → store.create_run → jobs 直接启动),返回 run_id。
339
- 测试可把本函数打成 stub,绝不真调 LLM/CLI。"""
340
- payload = {"type": t.get("flow") or DEFAULT_FLOW,
341
- "title": ("%s %s" % (t.get("name") or "定时任务",
342
- time.strftime("%m-%d %H:%M"))).strip()[:40],
343
- "goal": t.get("prompt") or "",
344
- "workdir": (t.get("workdir") or "").strip(),
345
- # 运行偏好与 Composer 同一套字段:create_task 里已支持,透传即可
346
- "mode": t.get("mode") or "auto",
347
- "thinking": t.get("thinking") or "standard"}
348
- # direct 流程才有对话模型;非 direct 流程这两个键连传都不传(避免碰 store
349
- # 里「有模型没厂商就报错」的校验,也避免给别的引擎塞无意义字段)
350
- if t.get("direct_provider_id"):
351
- payload["direct_provider_id"] = t["direct_provider_id"]
352
- payload["direct_model"] = t.get("direct_model") or ""
353
- task = None
354
- run = None
355
- try:
356
- task = store.create_task(payload)
357
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
358
- store.update_task_status(task["id"], "queued")
359
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
360
- "task_id": task["id"]})
361
- return run["id"]
362
- except Exception:
363
- # 启动失败必须收口,不能留下看似仍在启动的任务/run。
364
- # Keep the public error generic; the detailed traceback stays in the
365
- # service log and the run record remains useful for diagnostics.
366
- log.exception("automation: 运行启动失败 task=%s run=%s",
367
- (task or {}).get("id"), (run or {}).get("id"))
368
- if run:
369
- try:
370
- store.update_run(run["id"], status="failed",
371
- error="任务启动失败,本次未排队,请稍后重试",
372
- ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
373
- except Exception:
374
- log.exception("automation: 运行失败收口失败 run=%s", run.get("id"))
375
- elif task:
376
- # create_run may fail before a run is available. The task was
377
- # already persisted by create_task, so mark it failed as well.
378
- try:
379
- store.update_task_status(task["id"], "failed")
380
- except Exception:
381
- log.exception("automation: 任务失败收口失败 task=%s", task.get("id"))
382
- raise
383
-
384
-
385
- def _fire(snapshot, now):
386
- """触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
387
- once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
388
- status = "started"
389
- run_id = ""
390
- try:
391
- run_id = _launch_run(snapshot) or ""
392
- except Exception as e:
393
- log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
394
- status = "error"
395
- with _LOCK:
396
- cur = _TASKS.get(snapshot.get("id"))
397
- if cur is None:
398
- return
399
- cur["last_run"] = _fmt_dt(now)
400
- cur["run_count"] = int(cur.get("run_count") or 0) + 1
401
- cur["last_status"] = status
402
- if run_id:
403
- cur["last_run_id"] = run_id
404
- if cur.get("kind") == "once":
405
- cur["enabled"] = False
406
- cur["next_run"] = ""
407
- else:
408
- cur["next_run"] = compute_next_run(cur, now=now)
409
- _save_locked()
410
-
411
-
412
- # ---------------------------------------------------------------- 调度线程
413
-
414
- def _default_run_status(rid):
415
- """按 run_id 查运行状态(终态回写用);查不到返回 None。"""
416
- from . import store
417
- try:
418
- r = store.get_run(rid)
419
- except Exception:
420
- return None
421
- return (r or {}).get("status") or None
422
-
423
-
424
- # 测试可替换的查态钩子
425
- _RUN_STATUS = _default_run_status
426
-
427
- _RUN_TERMINAL = ("done", "failed", "cancelled")
428
-
429
-
430
- def _reconcile_statuses():
431
- """把 last_status 停在 started 的任务对账到真实运行终态。此前触发后永不
432
- 回写,任务卡片永远显示「运行中」(2026-09-23 实案:三个任务全部如此)。
433
- store 查不到或还没到终态就保持原样,下一轮 tick 再看。"""
434
- with _LOCK:
435
- pending = {tid: t.get("last_run_id") for tid, t in _TASKS.items()
436
- if t.get("last_run_id") and t.get("last_status") == "started"}
437
- if not pending:
438
- return
439
- settled = {}
440
- for tid, rid in pending.items():
441
- st = _RUN_STATUS(rid)
442
- if st in _RUN_TERMINAL:
443
- settled[tid] = st
444
- if not settled:
445
- return
446
- with _LOCK:
447
- dirty = False
448
- for tid, st in settled.items():
449
- t = _TASKS.get(tid)
450
- if (t and t.get("last_run_id") == pending[tid]
451
- and t.get("last_status") == "started"):
452
- t["last_status"] = st
453
- dirty = True
454
- if dirty:
455
- _save_locked()
456
-
457
- def _tick():
458
- """扫描一遍到期任务并逐个触发。单任务异常只跳过该条,绝不上抛。"""
459
- now = datetime.now()
460
- due = []
461
- with _LOCK:
462
- for tid in sorted(_TASKS.keys()):
463
- t = _TASKS[tid]
464
- if not t.get("enabled"):
465
- continue
466
- nr = _parse_dt(t.get("next_run") or "")
467
- if nr is not None and nr <= now:
468
- due.append(tid)
469
- for tid in due:
470
- try:
471
- with _LOCK:
472
- t = _TASKS.get(tid)
473
- if t is None or not t.get("enabled"):
474
- continue # 等待期间被删/被停:跳过
475
- _fire(dict(t), now)
476
- except Exception:
477
- log.exception("automation: tick 处理任务 %s 异常,已跳过", tid)
478
- # 运行终态对账:last_status 停在 started 的任务查一次真实终态(见函数注)。
479
- try:
480
- _reconcile_statuses()
481
- except Exception:
482
- log.debug("automation: 运行终态对账跳过", exc_info=True)
483
- # 定时发布联动(P2.5):任务级 auto_publish 标记到点触发批量发布。
484
- # 发布逻辑全在 publish/auto(护栏/幂等/单飞),这里只当调度宿主。
485
- try:
486
- from .publish import auto as _pub_auto
487
- n = _pub_auto.fire_due()
488
- if n:
489
- log.info("automation: 定时发布触发 %d 个任务", n)
490
- except Exception:
491
- log.debug("automation: 定时发布检查跳过", exc_info=True)
492
- # 禅道 Bug 扫描联动:fire_due 内部自节流(未启用/没到点零开销),
493
- # 对账回写也在同一入口,这里只当调度宿主(同定时发布模式)。
494
- try:
495
- from . import zentao as _zentao
496
- _zentao.fire_due()
497
- except Exception:
498
- log.debug("automation: 禅道扫描跳过", exc_info=True)
499
- # 微信群摘要联动:监控文件夹增量扫描(fire_due 自节流,同上)。
500
- try:
501
- from . import wxdigest as _wxd
502
- r = _wxd.fire_due()
503
- if r and not r.get("skipped") and (r.get("made") or r.get("error")):
504
- log.info("automation: 群摘要扫描完成 made=%s err=%s",
505
- r.get("made"), r.get("error") or "-")
506
- except Exception:
507
- log.debug("automation: 群摘要扫描跳过", exc_info=True)
508
- # 每日垃圾清理联动:启用且当天没清过才真跑(fire_due 自节流,同上)。
509
- try:
510
- from . import cleanup as _cleanup
511
- r = _cleanup.fire_due()
512
- if r and (r.get("freed") or r.get("errors")):
513
- log.info("automation: 每日清理完成 释放 %.1f MB%s",
514
- r.get("freed", 0) / 1048576.0,
515
- (",%d 项失败" % len(r.get("errors") or [])) if r.get("errors") else "")
516
- except Exception:
517
- log.debug("automation: 每日清理跳过", exc_info=True)
518
-
519
-
520
- def _loop():
521
- while True:
522
- try:
523
- _tick()
524
- except Exception:
525
- log.exception("automation: 调度 tick 异常,继续下一轮")
526
- time.sleep(TICK_SECONDS)
527
-
528
-
529
- def start():
530
- """主进程启动时调用一次:加载任务、做重启对账并拉起调度线程。返回任务数。
531
- 重复调用是安全的(幂等,不会起第二条线程)。"""
532
- global _STARTED
533
- with _LOCK:
534
- if _STARTED:
535
- return len(_TASKS)
536
- _STARTED = True
537
- n = load()
538
- _recover_after_restart()
539
- try:
540
- threading.Thread(target=_loop, name="automation-scheduler",
541
- daemon=True).start()
542
- except Exception:
543
- log.exception("automation: 调度线程启动失败(定时任务本轮不会触发)")
544
- return n
545
-
546
-
547
- # ---------------------------------------------------------------- CRUD + 控制
548
-
549
- def list_tasks():
550
- """全部定时任务(新在前)。返回副本,调用方随便改。"""
551
- _ensure_loaded()
552
- with _LOCK:
553
- return [dict(_TASKS[i]) for i in sorted(_TASKS.keys(), reverse=True)]
554
-
555
-
556
- def get_task(tid):
557
- _ensure_loaded()
558
- with _LOCK:
559
- t = _TASKS.get(tid)
560
- return dict(t) if t else None
561
-
562
-
563
- def create(payload):
564
- """创建定时任务(payload 为请求体 dict)。校验失败抛 ValueError。"""
565
- payload = payload if isinstance(payload, dict) else {}
566
- _ensure_loaded()
567
- t = dict(_DEFAULTS)
568
- t.update({k: payload.get(k) for k in _UPDATABLE})
569
- t["id"] = "auto-%s-%04d" % (time.strftime("%Y%m%d-%H%M%S"), secrets.randbelow(10000))
570
- t["enabled"] = bool(payload.get("enabled", True))
571
- t["created_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
572
- t["kind"] = payload.get("kind") or "daily"
573
- _validate_core(t)
574
- _apply_schedule(t)
575
- _apply_run_prefs(t) # 依赖 _apply_schedule 归一后的 flow 判引擎
576
- t["next_run"] = compute_next_run(t)
577
- with _LOCK:
578
- _TASKS[t["id"]] = t
579
- _save_locked()
580
- return dict(t)
581
-
582
-
583
- def update(tid, patch):
584
- """部分更新(patch 只影响出现的字段)。调度字段变动后重算 next_run。
585
- 任务不存在返回 None;校验失败抛 ValueError(不落盘、不改内存)。"""
586
- _ensure_loaded()
587
- patch = patch if isinstance(patch, dict) else {}
588
- with _LOCK:
589
- cur = _TASKS.get(tid)
590
- if cur is None:
591
- return None
592
- merged = dict(cur)
593
- for k in _UPDATABLE:
594
- if k in patch:
595
- merged[k] = patch[k]
596
- _validate_core(merged, check_workdir=("workdir" in patch))
597
- _apply_schedule(merged, check_flow=("flow" in patch))
598
- _apply_run_prefs(merged) # 改了 flow 就重判:非 direct 流程清掉残留模型
599
- merged["next_run"] = compute_next_run(merged)
600
- _TASKS[tid] = merged
601
- _save_locked()
602
- return dict(merged)
603
-
604
-
605
- def set_enabled(tid, enabled):
606
- """启用/停用。启用时重算 next_run;once 启用即成过去式则按「错过」停用。"""
607
- _ensure_loaded()
608
- with _LOCK:
609
- t = _TASKS.get(tid)
610
- if t is None:
611
- return None
612
- t["enabled"] = bool(enabled)
613
- t["next_run"] = compute_next_run(t) if t["enabled"] else ""
614
- if t["enabled"] and t.get("kind") == "once":
615
- nr = _parse_dt(t.get("next_run") or "")
616
- if nr is None or nr <= datetime.now():
617
- t["enabled"] = False
618
- t["next_run"] = ""
619
- t["last_status"] = "missed"
620
- _save_locked()
621
- return dict(t)
622
-
623
-
624
- def run_now(tid):
625
- """立即执行一次:不影响 next_run(到点仍照常触发)。返回 (task, run_id);
626
- 任务不存在返回 (None, None);拉起失败 last_status=error、run_id 为空串。"""
627
- _ensure_loaded()
628
- with _LOCK:
629
- exists = tid in _TASKS
630
- if not exists:
631
- return None, None
632
- now = datetime.now()
633
- run_id = ""
634
- status = "started"
635
- run_id = ""
636
- try:
637
- with _LOCK:
638
- snapshot = dict(_TASKS[tid])
639
- run_id = _launch_run(snapshot) or ""
640
- except Exception as e:
641
- log.warning("automation: 手动触发 %s 失败: %r", tid, e)
642
- status = "error"
643
- with _LOCK:
644
- t = _TASKS.get(tid)
645
- if t is None:
646
- return None, None
647
- # interval 任务的 last_run 是 next_run 的计算锚点(compute_next_run 按
648
- # last_run + N 小时向前滚),手动触发写进来会让重启重算把档期整体后移
649
- # ——2026-09-23 实案:22:28 手动跑一轮 + 23:43 服务重启,本该 23:53
650
- # 触发的档期被抹成 00:28。interval 只让调度触发推进 last_run;其余
651
- # kind 的 next_run 由 time/weekday 决定,last_run 仅作展示,照旧写。
652
- if t.get("kind") != "interval":
653
- t["last_run"] = _fmt_dt(now)
654
- t["run_count"] = int(t.get("run_count") or 0) + 1
655
- t["last_status"] = status
656
- if run_id:
657
- t["last_run_id"] = run_id
658
- _save_locked()
659
- return dict(t), run_id
660
-
661
-
662
- def delete(tid):
663
- """删除任务(运行产生的任务/运行记录不受影响,仍在任务列表里可查)。"""
664
- _ensure_loaded()
665
- with _LOCK:
666
- if tid not in _TASKS:
667
- return False
668
- del _TASKS[tid]
669
- _save_locked()
670
- return True
671
-
672
-
673
- # ---------------------------------------------------------------- 内置模板
674
-
675
- TEMPLATES = [
676
- {
677
- "id": "daily-repo-inspection",
678
- "name": "每日仓库巡检",
679
- "desc": "每天早上自动巡检一次仓库:汇总未提交变更、梳理 TODO/FIXME 待办与风险,"
680
- "生成当天的巡检报告 INSPECTION.md。",
681
- "prompt": "对本工作目录的代码仓库做一次巡检,只读分析,不要修改任何源码文件:\n"
682
- "1. 运行 git status 与 git diff --stat,汇总当前未提交的变更;"
683
- "再用 git log --oneline -15 概览最近的提交脉络。\n"
684
- "2. 扫描源码里的 TODO、FIXME、HACK 标记,列出仍然悬挂的条目,"
685
- "标注所在文件与行号。\n"
686
- "3. 检查是否有临时产物混进源码(调试脚本、遗留的 print/console 调试输出、"
687
- "没进忽略规则的生成文件)。\n"
688
- "4. 输出一份 markdown 巡检报告保存为 INSPECTION.md,分三节:"
689
- "「变更概览」「待办与风险」「今日建议」,每节不超过 10 条,直接给结论。",
690
- "suggested_kind": "daily",
691
- "suggested_time": "09:00",
692
- "suggested_flow": "research",
693
- },
694
- {
695
- "id": "serial-novel-advance",
696
- "name": "连载定时推进",
697
- "desc": "配合连载小说任务:到点自动读取故事圣经与已有章节,续写下一段剧情,"
698
- "并自检人设、时间线与伏笔的一致性。",
699
- "prompt": "继续推进本工作目录里的连载小说:\n"
700
- "1. 先读故事圣经(若有)与全部已有章节,回顾主线剧情、人物设定与未回收的伏笔,"
701
- "用 3 到 5 句话确认「接下来该发生什么」再动笔。\n"
702
- "2. 按既有单章字数与文风续写下一章:承接收尾章节的节奏,"
703
- "至少推进一条主线冲突,人物言行必须与圣经设定一致。\n"
704
- "3. 写完后自检:人称与时态是否统一、伏笔是否被误收、与前文章节有无矛盾,"
705
- "发现问题立即修订。\n"
706
- "4. 成稿按已有章节的命名规则保存,章末附一句话「下一章预告」。"
707
- "不要改动已完成章节的任何内容。",
708
- "suggested_kind": "interval",
709
- "suggested_time": "",
710
- "suggested_interval_hours": 12,
711
- "suggested_flow": "novel",
712
- },
713
- {
714
- "id": "weekly-experience-cleanup",
715
- "name": "经验库周整理",
716
- "desc": "每周一自动整理经验库:合并重复教训、按主题归纳、标记疑似过时的条目,"
717
- "产出精简整理报告(不直接改写原文件)。",
718
- "prompt": "整理本机 CodeBee 经验库(数据目录下 skills.json 的教训条目,"
719
- "含 id/title/content/category 等字段),只读分析原文件,把整理结果写成报告:\n"
720
- "1. 通读全部教训条目,找出语义重复或高度相似的条目,"
721
- "给出合并建议(保留哪条、并入哪些、合并后的表述)。\n"
722
- "2. 按「环境安装、编排流程、提示词技巧、踩坑预警」等主题归纳分组,"
723
- "指出每组里最经得起复用的 2 到 3 条。\n"
724
- "3. 标记疑似过时的条目(依赖已升级、场景已下线)并说明理由。\n"
725
- "4. 输出 markdown 报告保存为 EXPERIENCE-REVIEW.md:先给汇总数字"
726
- "(总数、重复组数、疑似过时数),再给分组明细与合并建议清单。"
727
- "不要直接改写 skills.json,合并动作等人工确认。",
728
- "suggested_kind": "weekly",
729
- "suggested_time": "09:30",
730
- "suggested_weekday": 0,
731
- "suggested_flow": "doc",
732
- },
733
- {
734
- "id": "weekly-dependency-security-scan",
735
- "name": "依赖与安全扫描",
736
- "desc": "每周五自动对项目做一次只读的依赖与安全巡检:清点直接依赖、"
737
- "排查敏感文件与明文密钥风险,给出升级整改建议。",
738
- "prompt": "对本工作目录的项目做一次只读的依赖与安全巡检,"
739
- "不要安装、升级或修改任何文件:\n"
740
- "1. 识别项目类型与依赖清单(package.json、requirements.txt、"
741
- "pyproject.toml、go.mod 等),列出直接依赖及声明版本。\n"
742
- "2. 排查敏感信息风险:.env、密钥文件、含明文 token 的配置是否被 git 跟踪"
743
- "(用 git ls-files 核对);源码中硬编码的 password、token、secret 字样。\n"
744
- "3. 检查依赖健康度:明显落后的大版本、已停止维护的包"
745
- "(凭依赖名与版本判断,不确定就标注「待人工确认」)。\n"
746
- "4. 输出 markdown 报告保存为 SECURITY-REVIEW.md,分「依赖清单」"
747
- "「风险发现(按严重程度排序)」「升级与整改建议」三节;只报告,不执行修复。",
748
- "suggested_kind": "weekly",
749
- "suggested_time": "18:00",
750
- "suggested_weekday": 4,
751
- "suggested_flow": "research",
752
- },
753
- ]
754
-
755
-
756
- def templates():
757
- """内置模板(供前端渲染卡片)。返回副本。"""
758
- return [dict(t) for t in TEMPLATES]
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
+ mode(编排模式), thinking(思考程度), direct_provider_id/direct_model(仅 direct 流程),
14
+ enabled, created_at, last_run, next_run, run_count, last_status}
15
+
16
+ 运行偏好(mode/thinking/对话模型)与新建任务 Composer 的三颗胶囊同一套取值,
17
+ 到点拉起时原样透传给 store.create_task——定时任务不是另一条执行链,用户在这里
18
+ 选的编排强度与模型偏好必须与手动建任务完全等价。
19
+
20
+ 重启语义:错过的 once 不补跑(启动恢复时直接停用);daily/weekly/interval
21
+ 重算 next_run 到下一个未来时刻即可,不追赶停机期间错过的周期。
22
+
23
+ 主进程接线(main.py,服务启动时调用一次):
24
+ from core import automation
25
+ n = automation.start()
26
+ HTTP 分支契约见仓库集成说明:GET/POST /api/automation、
27
+ GET/POST /api/automation/<id>、POST /api/automation/<id>/(toggle|run|delete)。
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import os
34
+ import re
35
+ import secrets
36
+ import threading
37
+ import time
38
+ from datetime import datetime, timedelta
39
+ from pathlib import Path
40
+
41
+ from . import jobs, paths, store
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ _LOCK = threading.RLock()
46
+ _FILE = paths.DATA_DIR / "automation.json"
47
+ _TASKS = {} # id → task dict(内存真源;落盘为 {"version":1,"tasks":[全部任务]})
48
+ _LOADED = False
49
+ _STARTED = False
50
+
51
+ TICK_SECONDS = 25 # 调度扫描间隔:到点触发误差不超过半个周期
52
+ KINDS = ("daily", "interval", "weekly", "once")
53
+ INTERVAL_MIN, INTERVAL_MAX = 1, 720 # interval_hours 合法区间(小时)
54
+ DEFAULT_FLOW = "doc" # 未指定编排流程时的兜底:review 引擎产出文档,适配巡检/整理类提示词
55
+
56
+ # 运行偏好取值(必须与 store.create_task 的口径一致,否则到点拉起时被静默改档)
57
+ MODES = ("auto", "fast", "expert", "manual")
58
+ THINKINGS = ("auto", "low", "standard", "high")
59
+
60
+ _TIME_RE = re.compile(r"\s*(\d{1,2}):(\d{1,2})\s*")
61
+
62
+ # 字段缺省(加载历史文件/脏数据时兜底;enabled 兜底为 False,绝不意外触发)
63
+ _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "time": "",
64
+ "interval_hours": 0, "weekday": -1, "run_at": "", "flow": "",
65
+ "mode": "auto", "thinking": "standard",
66
+ "direct_provider_id": "", "direct_model": "",
67
+ "enabled": False, "created_at": "", "last_run": "", "next_run": "",
68
+ "run_count": 0, "last_status": "", "last_run_id": ""}
69
+
70
+ # 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
71
+ _UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
72
+ "weekday", "run_at", "flow", "mode", "thinking",
73
+ "direct_provider_id", "direct_model", "enabled")
74
+
75
+
76
+ # ---------------------------------------------------------------- 时间工具
77
+
78
+ def _parse_dt(s):
79
+ """宽松解析 ISO/常用时间串(%Y-%m-%d %H:%M[:S]、带 T 的 ISO 均可)。失败返回 None。"""
80
+ try:
81
+ return datetime.fromisoformat(str(s or "").strip())
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def _fmt_dt(dt):
87
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
88
+
89
+
90
+ def _norm_hhmm(v):
91
+ """归一 "HH:MM"(24 小时制,容忍 9:5 这类输入)。非法返回 None。"""
92
+ m = _TIME_RE.match(str(v or ""))
93
+ if not m:
94
+ return None
95
+ h, mi = int(m.group(1)), int(m.group(2))
96
+ if not (0 <= h <= 23 and 0 <= mi <= 59):
97
+ return None
98
+ return "%02d:%02d" % (h, mi)
99
+
100
+
101
+ def compute_next_run(t, now=None):
102
+ """按任务的 kind 计算下一次运行时间(本地时间串)。算不出返回空串(tick 跳过)。
103
+
104
+ daily = 每天 HH:MM(已过今天点 → 明天,正确跨天)
105
+ weekly = 指定星期几的 HH:MM(已过本周点 → 下周,正确跨周)
106
+ interval= 每 N 小时:锚点取 last_run(无则 created_at),锚点早已过去时按 N
107
+ 小时整步推进到第一个未来时刻(停机不追赶、不突发补跑)
108
+ once = 即 run_at 本身(错过的由启动恢复停用,正常路径到点触发后即停用)
109
+ """
110
+ now = now or datetime.now()
111
+ kind = t.get("kind")
112
+ if kind == "daily":
113
+ hhmm = _norm_hhmm(t.get("time"))
114
+ if hhmm is None:
115
+ return ""
116
+ h, mi = hhmm.split(":")
117
+ cand = now.replace(hour=int(h), minute=int(mi), second=0, microsecond=0)
118
+ if cand <= now:
119
+ cand += timedelta(days=1)
120
+ return _fmt_dt(cand)
121
+ if kind == "weekly":
122
+ hhmm = _norm_hhmm(t.get("time"))
123
+ try:
124
+ wd = int(t.get("weekday"))
125
+ except (TypeError, ValueError):
126
+ return ""
127
+ if hhmm is None or not 0 <= wd <= 6:
128
+ return ""
129
+ h, mi = hhmm.split(":")
130
+ cand = now.replace(hour=int(h), minute=int(mi), second=0, microsecond=0)
131
+ cand += timedelta(days=(wd - now.weekday()) % 7)
132
+ if cand <= now:
133
+ cand += timedelta(days=7)
134
+ return _fmt_dt(cand)
135
+ if kind == "interval":
136
+ try:
137
+ n = max(INTERVAL_MIN, int(t.get("interval_hours")))
138
+ except (TypeError, ValueError):
139
+ return ""
140
+ anchor = _parse_dt(t.get("last_run")) or _parse_dt(t.get("created_at")) or now
141
+ if anchor > now:
142
+ return _fmt_dt(anchor)
143
+ k = int((now - anchor).total_seconds() // (n * 3600)) + 1
144
+ return _fmt_dt(anchor + timedelta(hours=n * k))
145
+ if kind == "once":
146
+ dt = _parse_dt(t.get("run_at"))
147
+ return _fmt_dt(dt) if dt else ""
148
+ return ""
149
+
150
+
151
+ # ---------------------------------------------------------------- 校验
152
+
153
+ def _validate_core(t, check_workdir=True):
154
+ """name/prompt/workdir 校验与归一(就地改写 t)。workdir 为空 = 跟随「默认
155
+ 保存路径」(与 store.create_task 口径一致,到点触发时由它兜底创建)。"""
156
+ name = str(t.get("name") or "").strip()
157
+ if not name:
158
+ raise ValueError("任务名称不能为空")
159
+ t["name"] = name[:60]
160
+ prompt = str(t.get("prompt") or "").strip()
161
+ if not prompt:
162
+ raise ValueError("执行提示词(prompt)不能为空")
163
+ t["prompt"] = prompt
164
+ workdir = str(t.get("workdir") or "").strip()
165
+ if workdir:
166
+ p = Path(workdir).expanduser()
167
+ if not p.is_absolute():
168
+ raise ValueError("工作目录必须是绝对路径")
169
+ if check_workdir and not p.is_dir():
170
+ raise ValueError("工作目录不存在: %s" % workdir)
171
+ t["workdir"] = str(p)
172
+ else:
173
+ t["workdir"] = ""
174
+
175
+
176
+ def _apply_schedule(t, check_flow=True):
177
+ """按 kind 校验并归一调度字段(就地改写 t)。缺字段/越界/once 时间已过均抛
178
+ ValueError——错过的一次性任务不补跑,所以创建/修改时直接拒绝过去时间。"""
179
+ kind = t.get("kind")
180
+ if kind not in KINDS:
181
+ raise ValueError("kind 必须是 %s 之一" % "、".join(KINDS))
182
+ if kind in ("daily", "weekly"):
183
+ hhmm = _norm_hhmm(t.get("time"))
184
+ if hhmm is None:
185
+ raise ValueError("time 必须是 24 小时制 HH:MM")
186
+ t["time"] = hhmm
187
+ if kind == "weekly":
188
+ try:
189
+ wd = int(t.get("weekday"))
190
+ except (TypeError, ValueError):
191
+ raise ValueError("weekday 必须是 0-6 的整数(0=周一)")
192
+ if not 0 <= wd <= 6:
193
+ raise ValueError("weekday 必须是 0-6 的整数(0=周一)")
194
+ t["weekday"] = wd
195
+ if kind == "interval":
196
+ try:
197
+ n = int(t.get("interval_hours"))
198
+ except (TypeError, ValueError):
199
+ raise ValueError("interval_hours 必须是 %d-%d 的整数" % (INTERVAL_MIN, INTERVAL_MAX))
200
+ if not INTERVAL_MIN <= n <= INTERVAL_MAX:
201
+ raise ValueError("interval_hours 必须是 %d-%d 的整数" % (INTERVAL_MIN, INTERVAL_MAX))
202
+ t["interval_hours"] = n
203
+ if kind == "once":
204
+ dt = _parse_dt(t.get("run_at"))
205
+ if dt is None:
206
+ raise ValueError("once 任务需要 run_at(ISO 时间,如 2026-09-20T09:30)")
207
+ if dt <= datetime.now():
208
+ raise ValueError("once 的 run_at 必须是未来时间(错过的一次性任务不补跑)")
209
+ t["run_at"] = _fmt_dt(dt)
210
+ flow = str(t.get("flow") or "").strip()
211
+ if flow:
212
+ if check_flow:
213
+ from . import flows as flows_mod
214
+ if flows_mod.get_flow(flow) is None:
215
+ raise ValueError("未知编排流程:%s(可选见 /api/flows)" % flow)
216
+ t["flow"] = flow
217
+ else:
218
+ t["flow"] = DEFAULT_FLOW
219
+
220
+
221
+ def _flow_engine(flow_id):
222
+ """流程引擎名(查不到返回空串)。"""
223
+ from . import flows as flows_mod
224
+ f = flows_mod.get_flow(str(flow_id or "").strip())
225
+ return (f or {}).get("engine") or ""
226
+
227
+
228
+ def _apply_run_prefs(t):
229
+ """校验并归一运行偏好(编排模式/思考程度/对话模型),就地改写 t。必须在
230
+ _apply_schedule 之后调用——只有那里才把 flow 归一成最终值。
231
+
232
+ mode/thinking 非法值一律收敛到默认(auto/standard),不抛异常:这两个是
233
+ 「偏好」不是「配置」,脏值不该把保存卡死。对话模型只在流程引擎为 direct
234
+ 时有意义:非 direct 流程一律清空,避免换流程后残留的模型绑定在下一次
235
+ 触发时静默生效(用户看不到却在生效的配置,比没有更糟)。direct 流程下
236
+ 「有模型没厂商」则明确报错——猜一个厂商等于把模型请求打到别家。
237
+ """
238
+ mode = str(t.get("mode") or "").strip().lower()
239
+ t["mode"] = mode if mode in MODES else "auto"
240
+ thinking = str(t.get("thinking") or "").strip().lower()
241
+ t["thinking"] = thinking if thinking in THINKINGS else "standard"
242
+ pid = str(t.get("direct_provider_id") or "").strip()[:80]
243
+ model = str(t.get("direct_model") or "").strip()[:160]
244
+ if _flow_engine(t.get("flow")) != "direct":
245
+ t["direct_provider_id"], t["direct_model"] = "", ""
246
+ return
247
+ if model and not pid:
248
+ raise ValueError("指定对话模型时必须同时选择厂商")
249
+ t["direct_provider_id"], t["direct_model"] = pid, model
250
+
251
+
252
+ # ---------------------------------------------------------------- 持久化
253
+
254
+ def _list_locked():
255
+ return [_TASKS[i] for i in sorted(_TASKS.keys())]
256
+
257
+
258
+ def _save_locked():
259
+ """整体落盘:tmp + os.replace 原子替换,坏一半也不会丢旧数据。"""
260
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
261
+ tmp = _FILE.with_suffix(".tmp")
262
+ tmp.write_text(json.dumps({"version": 1, "tasks": _list_locked()},
263
+ ensure_ascii=False, indent=2), encoding="utf-8")
264
+ os.replace(str(tmp), str(_FILE))
265
+ store.bump_state() # 落盘即状态变化:多端 SSE 尽快看到
266
+
267
+
268
+ def _normalize(d):
269
+ """把磁盘上的历史条目收敛到当前模型(脏字段兜底,绝不抛异常)。"""
270
+ t = dict(_DEFAULTS)
271
+ if isinstance(d, dict):
272
+ t.update({k: v for k, v in d.items() if k in _DEFAULTS})
273
+ if t["kind"] not in KINDS:
274
+ t["kind"] = ""
275
+ try:
276
+ t["run_count"] = max(0, int(t["run_count"]))
277
+ except (TypeError, ValueError):
278
+ t["run_count"] = 0
279
+ t["enabled"] = bool(t["enabled"])
280
+ return t
281
+
282
+
283
+ def load(force=False):
284
+ """从磁盘加载任务(幂等)。返回任务数。测试可重绑 _FILE 后 force=True。"""
285
+ global _LOADED
286
+ with _LOCK:
287
+ if _LOADED and not force:
288
+ return len(_TASKS)
289
+ _TASKS.clear()
290
+ try:
291
+ data = json.loads(_FILE.read_text(encoding="utf-8"))
292
+ except Exception:
293
+ data = {}
294
+ raw = data.get("tasks") if isinstance(data, dict) else data
295
+ for d in raw or []:
296
+ t = _normalize(d)
297
+ if t["id"]:
298
+ _TASKS[t["id"]] = t
299
+ _LOADED = True
300
+ return len(_TASKS)
301
+
302
+
303
+ def _ensure_loaded():
304
+ if not _LOADED:
305
+ load()
306
+
307
+
308
+ # ---------------------------------------------------------------- 启动恢复
309
+
310
+ def _recover_after_restart():
311
+ """服务重启后的调度对账:错过的 once 直接停用(不补跑);其余 kind 重算
312
+ next_run 到下一个未来时刻。必须在 load() 之后调用。返回改动条数。"""
313
+ now = datetime.now()
314
+ changed = 0
315
+ with _LOCK:
316
+ for t in _TASKS.values():
317
+ if not t.get("enabled"):
318
+ continue
319
+ if t.get("kind") == "once":
320
+ nr = _parse_dt(t.get("next_run") or "")
321
+ if nr is None or nr <= now:
322
+ t["enabled"] = False
323
+ t["next_run"] = ""
324
+ t["last_status"] = "missed"
325
+ changed += 1
326
+ else:
327
+ t["next_run"] = compute_next_run(t, now=now)
328
+ changed += 1
329
+ if changed:
330
+ _save_locked()
331
+ return changed
332
+
333
+
334
+ # ---------------------------------------------------------------- 运行拉起
335
+
336
+ def _launch_run(t):
337
+ """拉起一次真实编排运行:与 main.py 的 /api/tasks 走同一条链路
338
+ (store.create_task → store.create_run → jobs 直接启动),返回 run_id。
339
+ 测试可把本函数打成 stub,绝不真调 LLM/CLI。"""
340
+ payload = {"type": t.get("flow") or DEFAULT_FLOW,
341
+ "title": ("%s %s" % (t.get("name") or "定时任务",
342
+ time.strftime("%m-%d %H:%M"))).strip()[:40],
343
+ "goal": t.get("prompt") or "",
344
+ "workdir": (t.get("workdir") or "").strip(),
345
+ # 运行偏好与 Composer 同一套字段:create_task 里已支持,透传即可
346
+ "mode": t.get("mode") or "auto",
347
+ "thinking": t.get("thinking") or "standard"}
348
+ # direct 流程才有对话模型;非 direct 流程这两个键连传都不传(避免碰 store
349
+ # 里「有模型没厂商就报错」的校验,也避免给别的引擎塞无意义字段)
350
+ if t.get("direct_provider_id"):
351
+ payload["direct_provider_id"] = t["direct_provider_id"]
352
+ payload["direct_model"] = t.get("direct_model") or ""
353
+ task = None
354
+ run = None
355
+ try:
356
+ task = store.create_task(payload)
357
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
358
+ store.update_task_status(task["id"], "queued")
359
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
360
+ "task_id": task["id"]})
361
+ return run["id"]
362
+ except Exception:
363
+ # 启动失败必须收口,不能留下看似仍在启动的任务/run。
364
+ # Keep the public error generic; the detailed traceback stays in the
365
+ # service log and the run record remains useful for diagnostics.
366
+ log.exception("automation: 运行启动失败 task=%s run=%s",
367
+ (task or {}).get("id"), (run or {}).get("id"))
368
+ if run:
369
+ try:
370
+ store.update_run(run["id"], status="failed",
371
+ error="任务启动失败,本次未排队,请稍后重试",
372
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
373
+ except Exception:
374
+ log.exception("automation: 运行失败收口失败 run=%s", run.get("id"))
375
+ elif task:
376
+ # create_run may fail before a run is available. The task was
377
+ # already persisted by create_task, so mark it failed as well.
378
+ try:
379
+ store.update_task_status(task["id"], "failed")
380
+ except Exception:
381
+ log.exception("automation: 任务失败收口失败 task=%s", task.get("id"))
382
+ raise
383
+
384
+
385
+ def _fire(snapshot, now):
386
+ """触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
387
+ once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
388
+ status = "started"
389
+ run_id = ""
390
+ try:
391
+ run_id = _launch_run(snapshot) or ""
392
+ except Exception as e:
393
+ log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
394
+ status = "error"
395
+ with _LOCK:
396
+ cur = _TASKS.get(snapshot.get("id"))
397
+ if cur is None:
398
+ return
399
+ cur["last_run"] = _fmt_dt(now)
400
+ cur["run_count"] = int(cur.get("run_count") or 0) + 1
401
+ cur["last_status"] = status
402
+ if run_id:
403
+ cur["last_run_id"] = run_id
404
+ if cur.get("kind") == "once":
405
+ cur["enabled"] = False
406
+ cur["next_run"] = ""
407
+ else:
408
+ cur["next_run"] = compute_next_run(cur, now=now)
409
+ _save_locked()
410
+
411
+
412
+ # ---------------------------------------------------------------- 调度线程
413
+
414
+ def _default_run_status(rid):
415
+ """按 run_id 查运行状态(终态回写用);查不到返回 None。"""
416
+ from . import store
417
+ try:
418
+ r = store.get_run(rid)
419
+ except Exception:
420
+ return None
421
+ return (r or {}).get("status") or None
422
+
423
+
424
+ # 测试可替换的查态钩子
425
+ _RUN_STATUS = _default_run_status
426
+
427
+ _RUN_TERMINAL = ("done", "failed", "cancelled")
428
+
429
+
430
+ def _reconcile_statuses():
431
+ """把 last_status 停在 started 的任务对账到真实运行终态。此前触发后永不
432
+ 回写,任务卡片永远显示「运行中」(2026-09-23 实案:三个任务全部如此)。
433
+ store 查不到或还没到终态就保持原样,下一轮 tick 再看。"""
434
+ with _LOCK:
435
+ pending = {tid: t.get("last_run_id") for tid, t in _TASKS.items()
436
+ if t.get("last_run_id") and t.get("last_status") == "started"}
437
+ if not pending:
438
+ return
439
+ settled = {}
440
+ for tid, rid in pending.items():
441
+ st = _RUN_STATUS(rid)
442
+ if st in _RUN_TERMINAL:
443
+ settled[tid] = st
444
+ if not settled:
445
+ return
446
+ with _LOCK:
447
+ dirty = False
448
+ for tid, st in settled.items():
449
+ t = _TASKS.get(tid)
450
+ if (t and t.get("last_run_id") == pending[tid]
451
+ and t.get("last_status") == "started"):
452
+ t["last_status"] = st
453
+ dirty = True
454
+ if dirty:
455
+ _save_locked()
456
+
457
+ def _tick():
458
+ """扫描一遍到期任务并逐个触发。单任务异常只跳过该条,绝不上抛。"""
459
+ now = datetime.now()
460
+ due = []
461
+ with _LOCK:
462
+ for tid in sorted(_TASKS.keys()):
463
+ t = _TASKS[tid]
464
+ if not t.get("enabled"):
465
+ continue
466
+ nr = _parse_dt(t.get("next_run") or "")
467
+ if nr is not None and nr <= now:
468
+ due.append(tid)
469
+ for tid in due:
470
+ try:
471
+ with _LOCK:
472
+ t = _TASKS.get(tid)
473
+ if t is None or not t.get("enabled"):
474
+ continue # 等待期间被删/被停:跳过
475
+ _fire(dict(t), now)
476
+ except Exception:
477
+ log.exception("automation: tick 处理任务 %s 异常,已跳过", tid)
478
+ # 运行终态对账:last_status 停在 started 的任务查一次真实终态(见函数注)。
479
+ try:
480
+ _reconcile_statuses()
481
+ except Exception:
482
+ log.debug("automation: 运行终态对账跳过", exc_info=True)
483
+ # 定时发布联动(P2.5):任务级 auto_publish 标记到点触发批量发布。
484
+ # 发布逻辑全在 publish/auto(护栏/幂等/单飞),这里只当调度宿主。
485
+ try:
486
+ from .publish import auto as _pub_auto
487
+ n = _pub_auto.fire_due()
488
+ if n:
489
+ log.info("automation: 定时发布触发 %d 个任务", n)
490
+ except Exception:
491
+ log.debug("automation: 定时发布检查跳过", exc_info=True)
492
+ # 禅道 Bug 扫描联动:fire_due 内部自节流(未启用/没到点零开销),
493
+ # 对账回写也在同一入口,这里只当调度宿主(同定时发布模式)。
494
+ try:
495
+ from . import zentao as _zentao
496
+ _zentao.fire_due()
497
+ except Exception:
498
+ log.debug("automation: 禅道扫描跳过", exc_info=True)
499
+ # 微信群摘要联动:监控文件夹增量扫描(fire_due 自节流,同上)。
500
+ try:
501
+ from . import wxdigest as _wxd
502
+ r = _wxd.fire_due()
503
+ if r and not r.get("skipped") and (r.get("made") or r.get("error")):
504
+ log.info("automation: 群摘要扫描完成 made=%s err=%s",
505
+ r.get("made"), r.get("error") or "-")
506
+ except Exception:
507
+ log.debug("automation: 群摘要扫描跳过", exc_info=True)
508
+ # 每日垃圾清理联动:启用且当天没清过才真跑(fire_due 自节流,同上)。
509
+ try:
510
+ from . import cleanup as _cleanup
511
+ r = _cleanup.fire_due()
512
+ if r and (r.get("freed") or r.get("errors")):
513
+ log.info("automation: 每日清理完成 释放 %.1f MB%s",
514
+ r.get("freed", 0) / 1048576.0,
515
+ (",%d 项失败" % len(r.get("errors") or [])) if r.get("errors") else "")
516
+ except Exception:
517
+ log.debug("automation: 每日清理跳过", exc_info=True)
518
+
519
+
520
+ def _loop():
521
+ while True:
522
+ try:
523
+ _tick()
524
+ except Exception:
525
+ log.exception("automation: 调度 tick 异常,继续下一轮")
526
+ time.sleep(TICK_SECONDS)
527
+
528
+
529
+ def start():
530
+ """主进程启动时调用一次:加载任务、做重启对账并拉起调度线程。返回任务数。
531
+ 重复调用是安全的(幂等,不会起第二条线程)。"""
532
+ global _STARTED
533
+ with _LOCK:
534
+ if _STARTED:
535
+ return len(_TASKS)
536
+ _STARTED = True
537
+ n = load()
538
+ _recover_after_restart()
539
+ try:
540
+ threading.Thread(target=_loop, name="automation-scheduler",
541
+ daemon=True).start()
542
+ except Exception:
543
+ log.exception("automation: 调度线程启动失败(定时任务本轮不会触发)")
544
+ return n
545
+
546
+
547
+ # ---------------------------------------------------------------- CRUD + 控制
548
+
549
+ def list_tasks():
550
+ """全部定时任务(新在前)。返回副本,调用方随便改。"""
551
+ _ensure_loaded()
552
+ with _LOCK:
553
+ return [dict(_TASKS[i]) for i in sorted(_TASKS.keys(), reverse=True)]
554
+
555
+
556
+ def get_task(tid):
557
+ _ensure_loaded()
558
+ with _LOCK:
559
+ t = _TASKS.get(tid)
560
+ return dict(t) if t else None
561
+
562
+
563
+ def create(payload):
564
+ """创建定时任务(payload 为请求体 dict)。校验失败抛 ValueError。"""
565
+ payload = payload if isinstance(payload, dict) else {}
566
+ _ensure_loaded()
567
+ t = dict(_DEFAULTS)
568
+ t.update({k: payload.get(k) for k in _UPDATABLE})
569
+ t["id"] = "auto-%s-%04d" % (time.strftime("%Y%m%d-%H%M%S"), secrets.randbelow(10000))
570
+ t["enabled"] = bool(payload.get("enabled", True))
571
+ t["created_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
572
+ t["kind"] = payload.get("kind") or "daily"
573
+ _validate_core(t)
574
+ _apply_schedule(t)
575
+ _apply_run_prefs(t) # 依赖 _apply_schedule 归一后的 flow 判引擎
576
+ t["next_run"] = compute_next_run(t)
577
+ with _LOCK:
578
+ _TASKS[t["id"]] = t
579
+ _save_locked()
580
+ return dict(t)
581
+
582
+
583
+ def update(tid, patch):
584
+ """部分更新(patch 只影响出现的字段)。调度字段变动后重算 next_run。
585
+ 任务不存在返回 None;校验失败抛 ValueError(不落盘、不改内存)。"""
586
+ _ensure_loaded()
587
+ patch = patch if isinstance(patch, dict) else {}
588
+ with _LOCK:
589
+ cur = _TASKS.get(tid)
590
+ if cur is None:
591
+ return None
592
+ merged = dict(cur)
593
+ for k in _UPDATABLE:
594
+ if k in patch:
595
+ merged[k] = patch[k]
596
+ _validate_core(merged, check_workdir=("workdir" in patch))
597
+ _apply_schedule(merged, check_flow=("flow" in patch))
598
+ _apply_run_prefs(merged) # 改了 flow 就重判:非 direct 流程清掉残留模型
599
+ merged["next_run"] = compute_next_run(merged)
600
+ _TASKS[tid] = merged
601
+ _save_locked()
602
+ return dict(merged)
603
+
604
+
605
+ def set_enabled(tid, enabled):
606
+ """启用/停用。启用时重算 next_run;once 启用即成过去式则按「错过」停用。"""
607
+ _ensure_loaded()
608
+ with _LOCK:
609
+ t = _TASKS.get(tid)
610
+ if t is None:
611
+ return None
612
+ t["enabled"] = bool(enabled)
613
+ t["next_run"] = compute_next_run(t) if t["enabled"] else ""
614
+ if t["enabled"] and t.get("kind") == "once":
615
+ nr = _parse_dt(t.get("next_run") or "")
616
+ if nr is None or nr <= datetime.now():
617
+ t["enabled"] = False
618
+ t["next_run"] = ""
619
+ t["last_status"] = "missed"
620
+ _save_locked()
621
+ return dict(t)
622
+
623
+
624
+ def run_now(tid):
625
+ """立即执行一次:不影响 next_run(到点仍照常触发)。返回 (task, run_id);
626
+ 任务不存在返回 (None, None);拉起失败 last_status=error、run_id 为空串。"""
627
+ _ensure_loaded()
628
+ with _LOCK:
629
+ exists = tid in _TASKS
630
+ if not exists:
631
+ return None, None
632
+ now = datetime.now()
633
+ run_id = ""
634
+ status = "started"
635
+ run_id = ""
636
+ try:
637
+ with _LOCK:
638
+ snapshot = dict(_TASKS[tid])
639
+ run_id = _launch_run(snapshot) or ""
640
+ except Exception as e:
641
+ log.warning("automation: 手动触发 %s 失败: %r", tid, e)
642
+ status = "error"
643
+ with _LOCK:
644
+ t = _TASKS.get(tid)
645
+ if t is None:
646
+ return None, None
647
+ # interval 任务的 last_run 是 next_run 的计算锚点(compute_next_run 按
648
+ # last_run + N 小时向前滚),手动触发写进来会让重启重算把档期整体后移
649
+ # ——2026-09-23 实案:22:28 手动跑一轮 + 23:43 服务重启,本该 23:53
650
+ # 触发的档期被抹成 00:28。interval 只让调度触发推进 last_run;其余
651
+ # kind 的 next_run 由 time/weekday 决定,last_run 仅作展示,照旧写。
652
+ if t.get("kind") != "interval":
653
+ t["last_run"] = _fmt_dt(now)
654
+ t["run_count"] = int(t.get("run_count") or 0) + 1
655
+ t["last_status"] = status
656
+ if run_id:
657
+ t["last_run_id"] = run_id
658
+ _save_locked()
659
+ return dict(t), run_id
660
+
661
+
662
+ def delete(tid):
663
+ """删除任务(运行产生的任务/运行记录不受影响,仍在任务列表里可查)。"""
664
+ _ensure_loaded()
665
+ with _LOCK:
666
+ if tid not in _TASKS:
667
+ return False
668
+ del _TASKS[tid]
669
+ _save_locked()
670
+ return True
671
+
672
+
673
+ # ---------------------------------------------------------------- 内置模板
674
+
675
+ TEMPLATES = [
676
+ {
677
+ "id": "daily-repo-inspection",
678
+ "name": "每日仓库巡检",
679
+ "desc": "每天早上自动巡检一次仓库:汇总未提交变更、梳理 TODO/FIXME 待办与风险,"
680
+ "生成当天的巡检报告 INSPECTION.md。",
681
+ "prompt": "对本工作目录的代码仓库做一次巡检,只读分析,不要修改任何源码文件:\n"
682
+ "1. 运行 git status 与 git diff --stat,汇总当前未提交的变更;"
683
+ "再用 git log --oneline -15 概览最近的提交脉络。\n"
684
+ "2. 扫描源码里的 TODO、FIXME、HACK 标记,列出仍然悬挂的条目,"
685
+ "标注所在文件与行号。\n"
686
+ "3. 检查是否有临时产物混进源码(调试脚本、遗留的 print/console 调试输出、"
687
+ "没进忽略规则的生成文件)。\n"
688
+ "4. 输出一份 markdown 巡检报告保存为 INSPECTION.md,分三节:"
689
+ "「变更概览」「待办与风险」「今日建议」,每节不超过 10 条,直接给结论。",
690
+ "suggested_kind": "daily",
691
+ "suggested_time": "09:00",
692
+ "suggested_flow": "research",
693
+ },
694
+ {
695
+ "id": "serial-novel-advance",
696
+ "name": "连载定时推进",
697
+ "desc": "配合连载小说任务:到点自动读取故事圣经与已有章节,续写下一段剧情,"
698
+ "并自检人设、时间线与伏笔的一致性。",
699
+ "prompt": "继续推进本工作目录里的连载小说:\n"
700
+ "1. 先读故事圣经(若有)与全部已有章节,回顾主线剧情、人物设定与未回收的伏笔,"
701
+ "用 3 到 5 句话确认「接下来该发生什么」再动笔。\n"
702
+ "2. 按既有单章字数与文风续写下一章:承接收尾章节的节奏,"
703
+ "至少推进一条主线冲突,人物言行必须与圣经设定一致。\n"
704
+ "3. 写完后自检:人称与时态是否统一、伏笔是否被误收、与前文章节有无矛盾,"
705
+ "发现问题立即修订。\n"
706
+ "4. 成稿按已有章节的命名规则保存,章末附一句话「下一章预告」。"
707
+ "不要改动已完成章节的任何内容。",
708
+ "suggested_kind": "interval",
709
+ "suggested_time": "",
710
+ "suggested_interval_hours": 12,
711
+ "suggested_flow": "novel",
712
+ },
713
+ {
714
+ "id": "weekly-experience-cleanup",
715
+ "name": "经验库周整理",
716
+ "desc": "每周一自动整理经验库:合并重复教训、按主题归纳、标记疑似过时的条目,"
717
+ "产出精简整理报告(不直接改写原文件)。",
718
+ "prompt": "整理本机 CodeBee 经验库(数据目录下 skills.json 的教训条目,"
719
+ "含 id/title/content/category 等字段),只读分析原文件,把整理结果写成报告:\n"
720
+ "1. 通读全部教训条目,找出语义重复或高度相似的条目,"
721
+ "给出合并建议(保留哪条、并入哪些、合并后的表述)。\n"
722
+ "2. 按「环境安装、编排流程、提示词技巧、踩坑预警」等主题归纳分组,"
723
+ "指出每组里最经得起复用的 2 到 3 条。\n"
724
+ "3. 标记疑似过时的条目(依赖已升级、场景已下线)并说明理由。\n"
725
+ "4. 输出 markdown 报告保存为 EXPERIENCE-REVIEW.md:先给汇总数字"
726
+ "(总数、重复组数、疑似过时数),再给分组明细与合并建议清单。"
727
+ "不要直接改写 skills.json,合并动作等人工确认。",
728
+ "suggested_kind": "weekly",
729
+ "suggested_time": "09:30",
730
+ "suggested_weekday": 0,
731
+ "suggested_flow": "doc",
732
+ },
733
+ {
734
+ "id": "weekly-dependency-security-scan",
735
+ "name": "依赖与安全扫描",
736
+ "desc": "每周五自动对项目做一次只读的依赖与安全巡检:清点直接依赖、"
737
+ "排查敏感文件与明文密钥风险,给出升级整改建议。",
738
+ "prompt": "对本工作目录的项目做一次只读的依赖与安全巡检,"
739
+ "不要安装、升级或修改任何文件:\n"
740
+ "1. 识别项目类型与依赖清单(package.json、requirements.txt、"
741
+ "pyproject.toml、go.mod 等),列出直接依赖及声明版本。\n"
742
+ "2. 排查敏感信息风险:.env、密钥文件、含明文 token 的配置是否被 git 跟踪"
743
+ "(用 git ls-files 核对);源码中硬编码的 password、token、secret 字样。\n"
744
+ "3. 检查依赖健康度:明显落后的大版本、已停止维护的包"
745
+ "(凭依赖名与版本判断,不确定就标注「待人工确认」)。\n"
746
+ "4. 输出 markdown 报告保存为 SECURITY-REVIEW.md,分「依赖清单」"
747
+ "「风险发现(按严重程度排序)」「升级与整改建议」三节;只报告,不执行修复。",
748
+ "suggested_kind": "weekly",
749
+ "suggested_time": "18:00",
750
+ "suggested_weekday": 4,
751
+ "suggested_flow": "research",
752
+ },
753
+ ]
754
+
755
+
756
+ def templates():
757
+ """内置模板(供前端渲染卡片)。返回副本。"""
758
+ return [dict(t) for t in TEMPLATES]