yee88 0.4.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
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.
- yee88/cli/__init__.py +4 -0
- yee88/cli/cron.py +214 -0
- yee88/cli/reload.py +140 -0
- yee88/cli/run.py +7 -0
- yee88/cron/__init__.py +5 -0
- yee88/cron/manager.py +140 -0
- yee88/cron/models.py +13 -0
- yee88/cron/scheduler.py +57 -0
- yee88/markdown.py +2 -0
- yee88/progress.py +3 -0
- yee88/runner.py +1 -0
- yee88/runner_bridge.py +18 -0
- yee88/runners/mock.py +3 -0
- yee88/runtime_loader.py +4 -0
- yee88/settings.py +2 -1
- yee88/skills/yee88/SKILL.md +530 -0
- yee88/telegram/commands/__init__.py +2 -0
- yee88/telegram/commands/executor.py +5 -1
- yee88/telegram/commands/model.py +131 -4
- yee88/telegram/commands/topics.py +10 -0
- yee88/telegram/context.py +6 -0
- yee88/telegram/loop.py +63 -2
- yee88/telegram/onboarding.py +40 -0
- yee88/transport_runtime.py +21 -0
- {yee88-0.4.0.dist-info → yee88-0.6.0.dist-info}/METADATA +2 -1
- {yee88-0.4.0.dist-info → yee88-0.6.0.dist-info}/RECORD +29 -22
- {yee88-0.4.0.dist-info → yee88-0.6.0.dist-info}/WHEEL +0 -0
- {yee88-0.4.0.dist-info → yee88-0.6.0.dist-info}/entry_points.txt +0 -0
- {yee88-0.4.0.dist-info → yee88-0.6.0.dist-info}/licenses/LICENSE +0 -0
yee88/cli/__init__.py
CHANGED
|
@@ -91,6 +91,8 @@ from .config import (
|
|
|
91
91
|
config_set,
|
|
92
92
|
config_unset,
|
|
93
93
|
)
|
|
94
|
+
from .cron import app as cron_app
|
|
95
|
+
from .reload import reload_command
|
|
94
96
|
|
|
95
97
|
|
|
96
98
|
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
|
|
@@ -212,6 +214,8 @@ def create_app() -> typer.Typer:
|
|
|
212
214
|
app.command(name="onboarding-paths")(onboarding_paths)
|
|
213
215
|
app.command(name="plugins")(plugins_cmd)
|
|
214
216
|
app.add_typer(config_app, name="config")
|
|
217
|
+
app.add_typer(cron_app, name="cron")
|
|
218
|
+
app.command(name="reload")(reload_command)
|
|
215
219
|
app.callback()(app_main)
|
|
216
220
|
for engine_id in _engine_ids_for_cli():
|
|
217
221
|
help_text = f"Run with the {engine_id} engine."
|
yee88/cli/cron.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..config import HOME_CONFIG_PATH
|
|
10
|
+
from ..cron.manager import CronManager
|
|
11
|
+
from ..cron.models import CronJob
|
|
12
|
+
from ..settings import load_settings_if_exists
|
|
13
|
+
from ..engines import list_backend_ids
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Manage yee88 cron jobs")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_cron_manager() -> CronManager:
|
|
19
|
+
return CronManager(HOME_CONFIG_PATH.parent)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_project(project: str) -> None:
|
|
23
|
+
if not project:
|
|
24
|
+
return
|
|
25
|
+
result = load_settings_if_exists()
|
|
26
|
+
if result is None:
|
|
27
|
+
raise ValueError(f"未找到配置文件,无法验证项目: {project}")
|
|
28
|
+
settings, config_path = result
|
|
29
|
+
engine_ids = list_backend_ids()
|
|
30
|
+
projects_config = settings.to_projects_config(config_path=config_path, engine_ids=engine_ids)
|
|
31
|
+
if project.lower() not in projects_config.projects:
|
|
32
|
+
available = list(projects_config.projects.keys())
|
|
33
|
+
if available:
|
|
34
|
+
raise ValueError(f"未知项目: {project}。可用项目: {', '.join(available)}")
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f"未知项目: {project}。请先使用 'yee88 init' 注册项目")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_one_time(schedule: str) -> str:
|
|
40
|
+
"""解析一次性任务时间,支持相对时间和 ISO 8601 格式。"""
|
|
41
|
+
now = datetime.now()
|
|
42
|
+
|
|
43
|
+
# 相对时间格式: +30s, +5m, +2h, +1d
|
|
44
|
+
if schedule.startswith("+"):
|
|
45
|
+
match = re.match(r"\+(\d+)([smhd])", schedule)
|
|
46
|
+
if not match:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"无效的时间格式: {schedule}。使用 +30s, +5m, +2h, +1d 或 ISO 8601 (2026-02-01T10:00:00)"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
value, unit = int(match.group(1)), match.group(2)
|
|
52
|
+
delta = {
|
|
53
|
+
"s": timedelta(seconds=value),
|
|
54
|
+
"m": timedelta(minutes=value),
|
|
55
|
+
"h": timedelta(hours=value),
|
|
56
|
+
"d": timedelta(days=value),
|
|
57
|
+
}[unit]
|
|
58
|
+
|
|
59
|
+
return (now + delta).isoformat()
|
|
60
|
+
|
|
61
|
+
# ISO 8601 格式
|
|
62
|
+
try:
|
|
63
|
+
dt = datetime.fromisoformat(schedule)
|
|
64
|
+
if dt <= now:
|
|
65
|
+
raise ValueError("执行时间必须在未来")
|
|
66
|
+
return dt.isoformat()
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
if "执行时间必须在未来" in str(e):
|
|
69
|
+
raise
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"无效的时间格式: {schedule}。使用 +30s, +5m, +2h, +1d 或 ISO 8601 (2026-02-01T10:00:00)"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def add(
|
|
77
|
+
id: str = typer.Argument(...),
|
|
78
|
+
schedule: str = typer.Argument(...),
|
|
79
|
+
message: str = typer.Argument(...),
|
|
80
|
+
project: str = typer.Option("", "--project", "-p", help="项目别名(可选,如 takopi)"),
|
|
81
|
+
one_time: bool = typer.Option(False, "--one-time", "-o", help="一次性任务,执行后自动删除"),
|
|
82
|
+
):
|
|
83
|
+
try:
|
|
84
|
+
manager = get_cron_manager()
|
|
85
|
+
manager.load()
|
|
86
|
+
|
|
87
|
+
_validate_project(project)
|
|
88
|
+
|
|
89
|
+
if one_time:
|
|
90
|
+
schedule = _parse_one_time(schedule)
|
|
91
|
+
|
|
92
|
+
job = CronJob(
|
|
93
|
+
id=id,
|
|
94
|
+
schedule=schedule,
|
|
95
|
+
message=message,
|
|
96
|
+
project=project,
|
|
97
|
+
enabled=True,
|
|
98
|
+
one_time=one_time,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
manager.add(job)
|
|
102
|
+
|
|
103
|
+
if one_time:
|
|
104
|
+
typer.echo(f"✅ 已添加一次性任务: {id}")
|
|
105
|
+
typer.echo(f" 执行时间: {schedule[:19]}")
|
|
106
|
+
else:
|
|
107
|
+
typer.echo(f"✅ 已添加定时任务: {id}")
|
|
108
|
+
typer.echo(f" 时间: {schedule}")
|
|
109
|
+
if project:
|
|
110
|
+
typer.echo(f" 项目: {project}")
|
|
111
|
+
typer.echo(f" 消息: {message}")
|
|
112
|
+
|
|
113
|
+
except ValueError as e:
|
|
114
|
+
typer.echo(f"❌ 错误: {e}", err=True)
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def list(
|
|
120
|
+
show_all: bool = typer.Option(False, "--all", "-a"),
|
|
121
|
+
):
|
|
122
|
+
manager = get_cron_manager()
|
|
123
|
+
manager.load()
|
|
124
|
+
|
|
125
|
+
jobs = manager.list()
|
|
126
|
+
|
|
127
|
+
if not jobs:
|
|
128
|
+
typer.echo("暂无定时任务")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if not show_all:
|
|
132
|
+
jobs = [j for j in jobs if j.enabled]
|
|
133
|
+
|
|
134
|
+
typer.echo(f"{'ID':<20} {'TYPE':<8} {'SCHEDULE':<20} {'STATUS':<10} {'PROJECT'}")
|
|
135
|
+
typer.echo("-" * 90)
|
|
136
|
+
|
|
137
|
+
for job in jobs:
|
|
138
|
+
status = "✓ enabled" if job.enabled else "✗ disabled"
|
|
139
|
+
job_type = "once" if job.one_time else "cron"
|
|
140
|
+
schedule_display = job.schedule[:19] if job.one_time else job.schedule
|
|
141
|
+
if len(schedule_display) > 20:
|
|
142
|
+
schedule_display = schedule_display[:17] + "..."
|
|
143
|
+
project_display = job.project
|
|
144
|
+
if len(project_display) > 25:
|
|
145
|
+
project_display = "..." + project_display[-22:]
|
|
146
|
+
typer.echo(f"{job.id:<20} {job_type:<8} {schedule_display:<20} {status:<10} {project_display}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def enable(
|
|
151
|
+
id: str = typer.Argument(...),
|
|
152
|
+
):
|
|
153
|
+
manager = get_cron_manager()
|
|
154
|
+
manager.load()
|
|
155
|
+
|
|
156
|
+
if manager.enable(id):
|
|
157
|
+
typer.echo(f"✅ 已启用: {id}")
|
|
158
|
+
else:
|
|
159
|
+
typer.echo(f"❌ 未找到任务: {id}", err=True)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def disable(
|
|
165
|
+
id: str = typer.Argument(...),
|
|
166
|
+
):
|
|
167
|
+
manager = get_cron_manager()
|
|
168
|
+
manager.load()
|
|
169
|
+
|
|
170
|
+
if manager.disable(id):
|
|
171
|
+
typer.echo(f"⏸️ 已禁用: {id}")
|
|
172
|
+
else:
|
|
173
|
+
typer.echo(f"❌ 未找到任务: {id}", err=True)
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def remove(
|
|
179
|
+
id: str = typer.Argument(...),
|
|
180
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
181
|
+
):
|
|
182
|
+
manager = get_cron_manager()
|
|
183
|
+
manager.load()
|
|
184
|
+
|
|
185
|
+
if not force:
|
|
186
|
+
confirm = typer.confirm(f"确定要删除任务 '{id}' 吗?")
|
|
187
|
+
if not confirm:
|
|
188
|
+
typer.echo("已取消")
|
|
189
|
+
raise typer.Exit(0)
|
|
190
|
+
|
|
191
|
+
if manager.remove(id):
|
|
192
|
+
typer.echo(f"🗑️ 已删除: {id}")
|
|
193
|
+
else:
|
|
194
|
+
typer.echo(f"❌ 未找到任务: {id}", err=True)
|
|
195
|
+
raise typer.Exit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def run(
|
|
200
|
+
id: str = typer.Argument(...),
|
|
201
|
+
):
|
|
202
|
+
manager = get_cron_manager()
|
|
203
|
+
manager.load()
|
|
204
|
+
|
|
205
|
+
job = manager.get(id)
|
|
206
|
+
if not job:
|
|
207
|
+
typer.echo(f"❌ 未找到任务: {id}", err=True)
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
|
|
210
|
+
typer.echo(f"🚀 执行任务: {id}")
|
|
211
|
+
typer.echo(f" 路径: {job.project}")
|
|
212
|
+
typer.echo(f" 消息: {job.message}")
|
|
213
|
+
typer.echo(f" 计划时间: {job.schedule}")
|
|
214
|
+
typer.echo("✅ 测试执行完成")
|
yee88/cli/reload.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ..config import HOME_CONFIG_PATH, load_or_init_config
|
|
12
|
+
from ..lockfile import _pid_running, _read_lock_info, lock_path_for_config
|
|
13
|
+
from ..logging import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
_reload_requested = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_exec_args() -> tuple[str, list[str]]:
|
|
21
|
+
import shutil
|
|
22
|
+
yee88_path = shutil.which("yee88")
|
|
23
|
+
if yee88_path:
|
|
24
|
+
return yee88_path, ["yee88"]
|
|
25
|
+
executable = sys.executable
|
|
26
|
+
args = [executable, "-m", "yee88"]
|
|
27
|
+
return executable, args
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def request_reload() -> None:
|
|
31
|
+
global _reload_requested
|
|
32
|
+
_reload_requested = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def should_reload() -> bool:
|
|
36
|
+
return _reload_requested
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def do_exec_restart() -> None:
|
|
40
|
+
logger.info("reload.exec_restart", pid=os.getpid())
|
|
41
|
+
sys.stdout.flush()
|
|
42
|
+
sys.stderr.flush()
|
|
43
|
+
|
|
44
|
+
executable, args = _get_exec_args()
|
|
45
|
+
os.execv(executable, args)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _handle_sighup(signum: int, frame: object) -> None:
|
|
49
|
+
logger.info("reload.signal_received", signal="SIGHUP", pid=os.getpid())
|
|
50
|
+
request_reload()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install_reload_handler() -> None:
|
|
54
|
+
if hasattr(signal, "SIGHUP"):
|
|
55
|
+
signal.signal(signal.SIGHUP, _handle_sighup)
|
|
56
|
+
logger.debug("reload.handler_installed", signal="SIGHUP")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _find_running_instance(config_path: Path | None = None) -> tuple[int, Path] | None:
|
|
60
|
+
if config_path is None:
|
|
61
|
+
try:
|
|
62
|
+
_, config_path = load_or_init_config()
|
|
63
|
+
except Exception:
|
|
64
|
+
config_path = HOME_CONFIG_PATH
|
|
65
|
+
|
|
66
|
+
lock_path = lock_path_for_config(config_path)
|
|
67
|
+
lock_info = _read_lock_info(lock_path)
|
|
68
|
+
|
|
69
|
+
if lock_info is None or lock_info.pid is None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if not _pid_running(lock_info.pid):
|
|
73
|
+
lock_path.unlink(missing_ok=True)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return lock_info.pid, lock_path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def send_reload_signal(config_path: Path | None = None) -> bool:
|
|
80
|
+
result = _find_running_instance(config_path)
|
|
81
|
+
|
|
82
|
+
if result is None:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
pid, _ = result
|
|
86
|
+
|
|
87
|
+
if pid == os.getpid():
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
os.kill(pid, signal.SIGHUP)
|
|
92
|
+
return True
|
|
93
|
+
except (ProcessLookupError, PermissionError):
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_reload(
|
|
98
|
+
config_path: Path | None = None,
|
|
99
|
+
timeout: float = 10.0,
|
|
100
|
+
) -> None:
|
|
101
|
+
result = _find_running_instance(config_path)
|
|
102
|
+
|
|
103
|
+
if result is None:
|
|
104
|
+
typer.echo("No running yee88 instance found.", err=True)
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
|
|
107
|
+
pid, lock_path = result
|
|
108
|
+
|
|
109
|
+
if pid == os.getpid():
|
|
110
|
+
typer.echo("Cannot reload self. Run from another terminal.", err=True)
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
typer.echo(f"Sending reload signal to yee88 (PID: {pid})...")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
os.kill(pid, signal.SIGHUP)
|
|
117
|
+
except ProcessLookupError:
|
|
118
|
+
typer.echo("Process already exited.", err=True)
|
|
119
|
+
lock_path.unlink(missing_ok=True)
|
|
120
|
+
raise typer.Exit(code=1)
|
|
121
|
+
except PermissionError:
|
|
122
|
+
typer.echo(f"Permission denied: cannot signal PID {pid}", err=True)
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
125
|
+
typer.echo("✓ Reload signal sent. Process will restart with new code.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reload_command(
|
|
129
|
+
config_path: Path | None = typer.Option(
|
|
130
|
+
None,
|
|
131
|
+
"--config-path",
|
|
132
|
+
help="Path to config file",
|
|
133
|
+
),
|
|
134
|
+
timeout: float = typer.Option(
|
|
135
|
+
10.0,
|
|
136
|
+
"--timeout",
|
|
137
|
+
help="Timeout in seconds",
|
|
138
|
+
),
|
|
139
|
+
) -> None:
|
|
140
|
+
run_reload(config_path=config_path, timeout=timeout)
|
yee88/cli/run.py
CHANGED
|
@@ -16,6 +16,7 @@ from ..config import ConfigError, load_or_init_config
|
|
|
16
16
|
from ..engines import get_backend
|
|
17
17
|
from ..ids import RESERVED_CHAT_COMMANDS
|
|
18
18
|
from ..lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
|
19
|
+
from .reload import do_exec_restart, install_reload_handler, should_reload
|
|
19
20
|
from ..logging import get_logger, setup_logging
|
|
20
21
|
from ..runtime_loader import build_runtime_spec, resolve_plugins_allowlist
|
|
21
22
|
from ..settings import TakopiSettings, load_settings, load_settings_if_exists
|
|
@@ -299,6 +300,7 @@ def _run_auto_router(
|
|
|
299
300
|
)
|
|
300
301
|
lock_handle = acquire_config_lock_fn(config_path, lock_token)
|
|
301
302
|
runtime = spec.to_runtime(config_path=config_path)
|
|
303
|
+
install_reload_handler()
|
|
302
304
|
transport_backend.build_and_run(
|
|
303
305
|
final_notify=final_notify,
|
|
304
306
|
default_engine_override=default_engine_override,
|
|
@@ -306,6 +308,11 @@ def _run_auto_router(
|
|
|
306
308
|
transport_config=transport_config,
|
|
307
309
|
runtime=runtime,
|
|
308
310
|
)
|
|
311
|
+
if should_reload():
|
|
312
|
+
if lock_handle is not None:
|
|
313
|
+
lock_handle.release()
|
|
314
|
+
lock_handle = None
|
|
315
|
+
do_exec_restart()
|
|
309
316
|
except ConfigError as exc:
|
|
310
317
|
typer.echo(f"error: {exc}", err=True)
|
|
311
318
|
raise typer.Exit(code=1) from exc
|
yee88/cron/__init__.py
ADDED
yee88/cron/manager.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
import tomli_w
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from croniter import croniter
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from .models import CronJob
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CronManager:
|
|
11
|
+
def __init__(self, config_dir: Path):
|
|
12
|
+
self.file = config_dir / "cron.toml"
|
|
13
|
+
self.jobs: List[CronJob] = []
|
|
14
|
+
|
|
15
|
+
def _validate_project(self, project: str) -> None:
|
|
16
|
+
if not project:
|
|
17
|
+
return
|
|
18
|
+
path = Path(project).expanduser().resolve()
|
|
19
|
+
if path.exists() and path.is_dir():
|
|
20
|
+
git_dir = path / ".git"
|
|
21
|
+
if git_dir.exists():
|
|
22
|
+
return
|
|
23
|
+
raise ValueError(f"不是 git 仓库: {project}")
|
|
24
|
+
|
|
25
|
+
def load(self):
|
|
26
|
+
if not self.file.exists():
|
|
27
|
+
self.jobs = []
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
with open(self.file, "rb") as f:
|
|
31
|
+
data = tomllib.load(f)
|
|
32
|
+
|
|
33
|
+
self.jobs = [
|
|
34
|
+
CronJob(**job)
|
|
35
|
+
for job in data.get("jobs", [])
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def save(self):
|
|
39
|
+
data = {
|
|
40
|
+
"jobs": [
|
|
41
|
+
{
|
|
42
|
+
"id": job.id,
|
|
43
|
+
"schedule": job.schedule,
|
|
44
|
+
"message": job.message,
|
|
45
|
+
"project": job.project,
|
|
46
|
+
"enabled": job.enabled,
|
|
47
|
+
"last_run": job.last_run,
|
|
48
|
+
"next_run": job.next_run,
|
|
49
|
+
"one_time": job.one_time,
|
|
50
|
+
}
|
|
51
|
+
for job in self.jobs
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
with open(self.file, "wb") as f:
|
|
56
|
+
tomli_w.dump(data, f)
|
|
57
|
+
|
|
58
|
+
def add(self, job: CronJob) -> None:
|
|
59
|
+
self._validate_project(job.project)
|
|
60
|
+
|
|
61
|
+
if any(j.id == job.id for j in self.jobs):
|
|
62
|
+
raise ValueError(f"任务 ID 已存在: {job.id}")
|
|
63
|
+
|
|
64
|
+
self.jobs.append(job)
|
|
65
|
+
self.save()
|
|
66
|
+
|
|
67
|
+
def remove(self, job_id: str) -> bool:
|
|
68
|
+
original_len = len(self.jobs)
|
|
69
|
+
self.jobs = [j for j in self.jobs if j.id != job_id]
|
|
70
|
+
|
|
71
|
+
if len(self.jobs) < original_len:
|
|
72
|
+
self.save()
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def get(self, job_id: str) -> Optional[CronJob]:
|
|
77
|
+
for job in self.jobs:
|
|
78
|
+
if job.id == job_id:
|
|
79
|
+
return job
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def list(self) -> List[CronJob]:
|
|
83
|
+
return self.jobs
|
|
84
|
+
|
|
85
|
+
def enable(self, job_id: str) -> bool:
|
|
86
|
+
for job in self.jobs:
|
|
87
|
+
if job.id == job_id:
|
|
88
|
+
job.enabled = True
|
|
89
|
+
self.save()
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def disable(self, job_id: str) -> bool:
|
|
94
|
+
for job in self.jobs:
|
|
95
|
+
if job.id == job_id:
|
|
96
|
+
job.enabled = False
|
|
97
|
+
self.save()
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def get_due_jobs(self) -> List[CronJob]:
|
|
102
|
+
now = datetime.now()
|
|
103
|
+
due = []
|
|
104
|
+
one_time_completed = []
|
|
105
|
+
|
|
106
|
+
for job in self.jobs:
|
|
107
|
+
if not job.enabled:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# 一次性任务处理
|
|
111
|
+
if job.one_time:
|
|
112
|
+
try:
|
|
113
|
+
exec_time = datetime.fromisoformat(job.schedule)
|
|
114
|
+
if exec_time <= now:
|
|
115
|
+
due.append(job)
|
|
116
|
+
one_time_completed.append(job.id)
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
else:
|
|
120
|
+
# 周期性任务处理
|
|
121
|
+
try:
|
|
122
|
+
base = datetime.fromisoformat(job.last_run) if job.last_run else now
|
|
123
|
+
itr = croniter(job.schedule, base)
|
|
124
|
+
next_run = itr.get_next(datetime)
|
|
125
|
+
|
|
126
|
+
if next_run <= now:
|
|
127
|
+
due.append(job)
|
|
128
|
+
job.last_run = now.isoformat()
|
|
129
|
+
job.next_run = itr.get_next(datetime).isoformat()
|
|
130
|
+
except Exception:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# 删除已完成的一次性任务
|
|
134
|
+
if one_time_completed:
|
|
135
|
+
self.jobs = [j for j in self.jobs if j.id not in one_time_completed]
|
|
136
|
+
|
|
137
|
+
if due:
|
|
138
|
+
self.save()
|
|
139
|
+
|
|
140
|
+
return due
|
yee88/cron/models.py
ADDED
yee88/cron/scheduler.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Callable, Awaitable
|
|
3
|
+
from anyio.abc import TaskGroup
|
|
4
|
+
from .manager import CronManager
|
|
5
|
+
from .models import CronJob
|
|
6
|
+
from ..logging import get_logger
|
|
7
|
+
|
|
8
|
+
logger = get_logger()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CronScheduler:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
manager: CronManager,
|
|
15
|
+
callback: Callable[[CronJob], Awaitable[None]],
|
|
16
|
+
task_group: TaskGroup,
|
|
17
|
+
):
|
|
18
|
+
self.manager = manager
|
|
19
|
+
self.callback = callback
|
|
20
|
+
self.task_group = task_group
|
|
21
|
+
self.running = False
|
|
22
|
+
|
|
23
|
+
async def start(self):
|
|
24
|
+
self.running = True
|
|
25
|
+
self.manager.load()
|
|
26
|
+
logger.info("cron.scheduler.started", job_count=len(self.manager.jobs))
|
|
27
|
+
|
|
28
|
+
cycle = 0
|
|
29
|
+
while self.running:
|
|
30
|
+
cycle += 1
|
|
31
|
+
self.manager.load()
|
|
32
|
+
logger.info("cron.scheduler.check_cycle", cycle=cycle, job_count=len(self.manager.jobs))
|
|
33
|
+
due_jobs = self.manager.get_due_jobs()
|
|
34
|
+
|
|
35
|
+
if due_jobs:
|
|
36
|
+
logger.info("cron.scheduler.due_jobs_found", cycle=cycle, count=len(due_jobs), job_ids=[j.id for j in due_jobs])
|
|
37
|
+
else:
|
|
38
|
+
logger.info("cron.scheduler.no_due_jobs", cycle=cycle)
|
|
39
|
+
|
|
40
|
+
for job in due_jobs:
|
|
41
|
+
logger.info("cron.scheduler.dispatching_job", job_id=job.id, message=job.message[:50])
|
|
42
|
+
self.task_group.start_soon(self._run_job_safe, job)
|
|
43
|
+
|
|
44
|
+
logger.info("cron.scheduler.sleeping", cycle=cycle, seconds=60)
|
|
45
|
+
await asyncio.sleep(60)
|
|
46
|
+
|
|
47
|
+
async def _run_job_safe(self, job: CronJob) -> None:
|
|
48
|
+
logger.info("cron.job.executing", job_id=job.id)
|
|
49
|
+
try:
|
|
50
|
+
await self.callback(job)
|
|
51
|
+
logger.info("cron.job.completed", job_id=job.id)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
logger.error("cron.job.failed", job_id=job.id, error=str(exc))
|
|
54
|
+
|
|
55
|
+
def stop(self):
|
|
56
|
+
self.running = False
|
|
57
|
+
logger.info("cron.scheduler.stopped")
|
yee88/markdown.py
CHANGED
|
@@ -244,6 +244,8 @@ class MarkdownFormatter:
|
|
|
244
244
|
lines.append(state.context_line)
|
|
245
245
|
if state.resume_line:
|
|
246
246
|
lines.append(state.resume_line)
|
|
247
|
+
if state.model:
|
|
248
|
+
lines.append(f"`model: {state.model}`")
|
|
247
249
|
if not lines:
|
|
248
250
|
return None
|
|
249
251
|
return HARD_BREAK.join(lines)
|
yee88/progress.py
CHANGED
|
@@ -25,6 +25,7 @@ class ProgressState:
|
|
|
25
25
|
resume: ResumeToken | None
|
|
26
26
|
resume_line: str | None
|
|
27
27
|
context_line: str | None
|
|
28
|
+
model: str | None = None
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
class ProgressTracker:
|
|
@@ -82,6 +83,7 @@ class ProgressTracker:
|
|
|
82
83
|
*,
|
|
83
84
|
resume_formatter: Callable[[ResumeToken], str] | None = None,
|
|
84
85
|
context_line: str | None = None,
|
|
86
|
+
model: str | None = None,
|
|
85
87
|
) -> ProgressState:
|
|
86
88
|
resume_line: str | None = None
|
|
87
89
|
if self.resume is not None and resume_formatter is not None:
|
|
@@ -96,4 +98,5 @@ class ProgressTracker:
|
|
|
96
98
|
resume=self.resume,
|
|
97
99
|
resume_line=resume_line,
|
|
98
100
|
context_line=context_line,
|
|
101
|
+
model=model,
|
|
99
102
|
)
|