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 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
@@ -0,0 +1,5 @@
1
+ from .models import CronJob
2
+ from .manager import CronManager
3
+ from .scheduler import CronScheduler
4
+
5
+ __all__ = ["CronJob", "CronManager", "CronScheduler"]
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
@@ -0,0 +1,13 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class CronJob:
6
+ id: str
7
+ schedule: str
8
+ message: str
9
+ project: str
10
+ enabled: bool = True
11
+ last_run: str = ""
12
+ next_run: str = ""
13
+ one_time: bool = False
@@ -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
  )
yee88/runner.py CHANGED
@@ -698,6 +698,7 @@ class JsonlSubprocessRunner(BaseRunner):
698
698
 
699
699
  class Runner(Protocol):
700
700
  engine: str
701
+ model: str | None
701
702
 
702
703
  def is_resume_line(self, line: str) -> bool: ...
703
704