coderfleet 0.1.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.
- coderfleet/__init__.py +1 -0
- coderfleet/__main__.py +4 -0
- coderfleet/cli.py +212 -0
- coderfleet/compose.py +176 -0
- coderfleet/config.py +69 -0
- coderfleet/config_cmds.py +243 -0
- coderfleet/data/Dockerfile +92 -0
- coderfleet/data/__init__.py +0 -0
- coderfleet/data/accounts.conf.example +26 -0
- coderfleet/data/config.conf.example +31 -0
- coderfleet/data/entrypoint.sh +56 -0
- coderfleet/data/projects.conf.example +17 -0
- coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
- coderfleet/docker_ops.py +385 -0
- coderfleet/init_wizard.py +227 -0
- coderfleet/login_cmd.py +168 -0
- coderfleet/server/__init__.py +0 -0
- coderfleet/server/docker_mgr.py +45 -0
- coderfleet/server/main.py +546 -0
- coderfleet/server/models.py +285 -0
- coderfleet/server/scheduler.py +1219 -0
- coderfleet/server/static/css/main.css +2906 -0
- coderfleet/server/static/index.html +378 -0
- coderfleet/server/static/js/accounts.js +85 -0
- coderfleet/server/static/js/app.js +28 -0
- coderfleet/server/static/js/chat.js +743 -0
- coderfleet/server/static/js/log.js +145 -0
- coderfleet/server/static/js/nav.js +46 -0
- coderfleet/server/static/js/projects.js +298 -0
- coderfleet/server/static/js/renderer.js +586 -0
- coderfleet/server/static/js/state.js +76 -0
- coderfleet/server/static/js/submit.js +200 -0
- coderfleet/server/static/js/tasks.js +92 -0
- coderfleet/server/static/js/terminal.js +347 -0
- coderfleet/server/static/js/utils.js +147 -0
- coderfleet/server/static/vendor/marked.min.js +6 -0
- coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
- coderfleet/server/static/vendor/xterm/xterm.css +218 -0
- coderfleet/server/static/vendor/xterm/xterm.js +2 -0
- coderfleet/server/terminal.py +129 -0
- coderfleet/task_cmds.py +311 -0
- coderfleet-0.1.0.dist-info/METADATA +492 -0
- coderfleet-0.1.0.dist-info/RECORD +45 -0
- coderfleet-0.1.0.dist-info/WHEEL +4 -0
- coderfleet-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import fcntl
|
|
5
|
+
import os
|
|
6
|
+
import pty
|
|
7
|
+
import signal
|
|
8
|
+
import struct
|
|
9
|
+
import termios
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from coderfleet.server import docker_mgr
|
|
13
|
+
from coderfleet.server.models import Account, Project
|
|
14
|
+
from coderfleet.server.scheduler import Scheduler
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TerminalTarget:
|
|
19
|
+
project: Project
|
|
20
|
+
account: Account
|
|
21
|
+
container_name: str
|
|
22
|
+
container_workdir: str
|
|
23
|
+
command: list[str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_terminal_command(container_name: str, container_workdir: str) -> list[str]:
|
|
27
|
+
return ["docker", "exec", "-it", "-w", container_workdir, container_name, "bash", "-l"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_terminal_target(scheduler: Scheduler, project_name: str) -> TerminalTarget:
|
|
31
|
+
project = scheduler.find_project_by_name(project_name)
|
|
32
|
+
if project is None:
|
|
33
|
+
raise ValueError(f"项目 '{project_name}' 不存在")
|
|
34
|
+
|
|
35
|
+
account = next((acc for acc in scheduler.get_accounts() if acc.name == project.account), None)
|
|
36
|
+
if account is None:
|
|
37
|
+
raise ValueError(f"账号 '{project.account}' 不存在")
|
|
38
|
+
|
|
39
|
+
container_name = project.container_name(account.type)
|
|
40
|
+
if not docker_mgr.is_container_running(container_name):
|
|
41
|
+
raise RuntimeError(f"容器 {container_name} 未运行")
|
|
42
|
+
|
|
43
|
+
container_workdir = scheduler.container_workdir_for_project(project, project.path)
|
|
44
|
+
command = build_terminal_command(container_name, container_workdir)
|
|
45
|
+
return TerminalTarget(
|
|
46
|
+
project=project,
|
|
47
|
+
account=account,
|
|
48
|
+
container_name=container_name,
|
|
49
|
+
container_workdir=container_workdir,
|
|
50
|
+
command=command,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resize_pty(fd: int, cols: int, rows: int) -> None:
|
|
55
|
+
if cols <= 0 or rows <= 0:
|
|
56
|
+
return
|
|
57
|
+
size = struct.pack("HHHH", rows, cols, 0, 0)
|
|
58
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, size)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TerminalSession:
|
|
62
|
+
def __init__(self, command: list[str], project_name: str):
|
|
63
|
+
self.command = command
|
|
64
|
+
self.project_name = project_name
|
|
65
|
+
self.master_fd: int | None = None
|
|
66
|
+
self.child_pid: int | None = None
|
|
67
|
+
|
|
68
|
+
def start(self) -> None:
|
|
69
|
+
if self.master_fd is not None or self.child_pid is not None:
|
|
70
|
+
raise RuntimeError("terminal session already started")
|
|
71
|
+
|
|
72
|
+
child_pid, master_fd = pty.fork()
|
|
73
|
+
if child_pid == 0:
|
|
74
|
+
os.execvp(self.command[0], self.command)
|
|
75
|
+
self.child_pid = child_pid
|
|
76
|
+
self.master_fd = master_fd
|
|
77
|
+
os.set_blocking(master_fd, False)
|
|
78
|
+
|
|
79
|
+
async def read(self) -> bytes:
|
|
80
|
+
if self.master_fd is None:
|
|
81
|
+
return b""
|
|
82
|
+
loop = asyncio.get_running_loop()
|
|
83
|
+
return await loop.run_in_executor(None, self._read_once)
|
|
84
|
+
|
|
85
|
+
def _read_once(self) -> bytes:
|
|
86
|
+
if self.master_fd is None:
|
|
87
|
+
return b""
|
|
88
|
+
try:
|
|
89
|
+
return os.read(self.master_fd, 4096)
|
|
90
|
+
except BlockingIOError:
|
|
91
|
+
return b""
|
|
92
|
+
except OSError:
|
|
93
|
+
return b""
|
|
94
|
+
|
|
95
|
+
def write(self, data: str) -> None:
|
|
96
|
+
if self.master_fd is None or not data:
|
|
97
|
+
return
|
|
98
|
+
os.write(self.master_fd, data.encode("utf-8", errors="ignore"))
|
|
99
|
+
|
|
100
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
101
|
+
if self.master_fd is None:
|
|
102
|
+
return
|
|
103
|
+
resize_pty(self.master_fd, cols, rows)
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
child_pid = self.child_pid
|
|
107
|
+
master_fd = self.master_fd
|
|
108
|
+
self.child_pid = None
|
|
109
|
+
self.master_fd = None
|
|
110
|
+
|
|
111
|
+
if child_pid is not None:
|
|
112
|
+
try:
|
|
113
|
+
os.kill(child_pid, signal.SIGHUP)
|
|
114
|
+
except ProcessLookupError:
|
|
115
|
+
pass
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
try:
|
|
119
|
+
os.waitpid(child_pid, os.WNOHANG)
|
|
120
|
+
except ChildProcessError:
|
|
121
|
+
pass
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
if master_fd is not None:
|
|
126
|
+
try:
|
|
127
|
+
os.close(master_fd)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
coderfleet/task_cmds.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
task_cmds.py — coderfleet task 子命令
|
|
3
|
+
|
|
4
|
+
通过 HTTP 调用 CoderFleet 调度服务 API,需先执行 coderfleet server 启动服务。
|
|
5
|
+
CODERFLEET_API 环境变量可覆盖服务地址(默认 http://localhost:8765)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── 工具函数 ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def _api_base() -> str:
|
|
21
|
+
return os.environ.get("CODERFLEET_API", "http://localhost:8765").rstrip("/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_server() -> str:
|
|
25
|
+
"""检查服务是否在运行,返回 api base url。失败则 abort。"""
|
|
26
|
+
api = _api_base()
|
|
27
|
+
try:
|
|
28
|
+
httpx.get(f"{api}/api/health", timeout=3).raise_for_status()
|
|
29
|
+
except Exception:
|
|
30
|
+
raise click.ClickException(
|
|
31
|
+
f"CoderFleet 服务未启动,请先执行:coderfleet server\n"
|
|
32
|
+
f"(或设置 CODERFLEET_API=<地址> 指向远端服务)"
|
|
33
|
+
)
|
|
34
|
+
return api
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_project_field(prefer_project: str) -> tuple[str, str]:
|
|
38
|
+
"""
|
|
39
|
+
判断 --project 值是路径还是项目名:
|
|
40
|
+
- 绝对路径 / ~ 开头 / ./ 开头 → project 字段(路径匹配)
|
|
41
|
+
- 其他字符串 → project_name 字段(名称匹配)
|
|
42
|
+
返回 (field_name, value)
|
|
43
|
+
"""
|
|
44
|
+
expanded = os.path.expanduser(prefer_project)
|
|
45
|
+
if (
|
|
46
|
+
os.path.isabs(expanded)
|
|
47
|
+
or prefer_project.startswith("~")
|
|
48
|
+
or prefer_project.startswith("./")
|
|
49
|
+
):
|
|
50
|
+
return "project", str(Path(expanded).resolve())
|
|
51
|
+
return "project_name", prefer_project
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_STATUS_STYLE = {
|
|
55
|
+
"running": ("green", "● 运行中"),
|
|
56
|
+
"pending": ("yellow", "○ 排队中"),
|
|
57
|
+
"scheduled": ("blue", "◷ 定时中"),
|
|
58
|
+
"done": ("cyan", "✓ 完成 "),
|
|
59
|
+
"failed": ("red", "✗ 失败 "),
|
|
60
|
+
"killed": ("yellow", "⊘ 已终止"),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Click 命令组 ──────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
@click.group("task")
|
|
67
|
+
def task_group() -> None:
|
|
68
|
+
"""Task management commands (requires server running)."""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── task run ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
@task_group.command("run")
|
|
75
|
+
@click.argument("prompt")
|
|
76
|
+
@click.option("--project", "prefer_project", default=None, help="项目名称或宿主机路径")
|
|
77
|
+
@click.option("--account", default=None, help="指定账号名")
|
|
78
|
+
@click.option("--type", "prefer_type", default=None,
|
|
79
|
+
type=click.Choice(["codex", "claude"]), help="指定账号类型")
|
|
80
|
+
@click.option("--auto", is_flag=True, help="全自动模式(跳过权限确认)")
|
|
81
|
+
@click.option("--conversation", "conversation_id", default=None, help="续接已有任务链 ID")
|
|
82
|
+
@click.option("--new-chain", "conversation_name", default=None, help="新建任务链并命名")
|
|
83
|
+
@click.option("--at", "execute_at", default=None, help="定时执行时间(ISO 8601)")
|
|
84
|
+
def cmd_task_run(
|
|
85
|
+
prompt: str,
|
|
86
|
+
prefer_project: Optional[str],
|
|
87
|
+
account: Optional[str],
|
|
88
|
+
prefer_type: Optional[str],
|
|
89
|
+
auto: bool,
|
|
90
|
+
conversation_id: Optional[str],
|
|
91
|
+
conversation_name: Optional[str],
|
|
92
|
+
execute_at: Optional[str],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Submit a task for execution."""
|
|
95
|
+
api = _require_server()
|
|
96
|
+
|
|
97
|
+
body: dict = {"prompt": prompt, "auto": auto}
|
|
98
|
+
|
|
99
|
+
if prefer_project:
|
|
100
|
+
field, value = _resolve_project_field(prefer_project)
|
|
101
|
+
body[field] = value
|
|
102
|
+
if account:
|
|
103
|
+
body["account"] = account
|
|
104
|
+
if prefer_type:
|
|
105
|
+
body["type"] = prefer_type
|
|
106
|
+
if conversation_id:
|
|
107
|
+
body["conversation_id"] = conversation_id
|
|
108
|
+
if conversation_name:
|
|
109
|
+
body["conversation_name"] = conversation_name
|
|
110
|
+
if execute_at:
|
|
111
|
+
body["execute_at"] = execute_at
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
r = httpx.post(f"{api}/api/tasks", json=body, timeout=10)
|
|
115
|
+
except httpx.RequestError as e:
|
|
116
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
117
|
+
|
|
118
|
+
if r.status_code != 201:
|
|
119
|
+
detail = r.json().get("detail", r.text) if r.headers.get("content-type", "").startswith("application/json") else r.text
|
|
120
|
+
raise click.ClickException(f"提交失败(HTTP {r.status_code}):{detail}")
|
|
121
|
+
|
|
122
|
+
d = r.json()
|
|
123
|
+
conv = d.get("conversation_id", "")
|
|
124
|
+
project_label = d["project"].split("/")[-1] if d.get("project") else d.get("project_name", "")
|
|
125
|
+
|
|
126
|
+
click.secho(f"✓ 任务已提交:{d['id']}", fg="green")
|
|
127
|
+
click.echo(f" 项目:{project_label} 账号:{d['account']} ({d['type']})")
|
|
128
|
+
if d.get("status") == "scheduled":
|
|
129
|
+
click.echo(f" 定时:{d.get('execute_at')}")
|
|
130
|
+
elif d.get("status") == "pending":
|
|
131
|
+
click.secho(f" 状态:排队中(账号忙碌)", fg="yellow")
|
|
132
|
+
if conv:
|
|
133
|
+
click.echo(f" 任务链:{conv}")
|
|
134
|
+
click.echo(f" 查看日志:coderfleet task logs {d['id']}")
|
|
135
|
+
click.echo(f" 实时跟踪:coderfleet task logs {d['id']} -f")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── task list ─────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
@task_group.command("list")
|
|
141
|
+
@click.option("--account", default=None, help="按账号名过滤")
|
|
142
|
+
@click.option("--status", "status_filter", default=None,
|
|
143
|
+
type=click.Choice(["running", "pending", "scheduled", "done", "failed", "killed"]),
|
|
144
|
+
help="按状态过滤")
|
|
145
|
+
@click.option("--limit", default=200, show_default=True, help="最多显示条数")
|
|
146
|
+
@click.option("--all", "include_archived", is_flag=True, help="包含已归档的任务")
|
|
147
|
+
def cmd_task_list(
|
|
148
|
+
account: Optional[str],
|
|
149
|
+
status_filter: Optional[str],
|
|
150
|
+
limit: int,
|
|
151
|
+
include_archived: bool,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""List tasks."""
|
|
154
|
+
api = _require_server()
|
|
155
|
+
|
|
156
|
+
params: dict = {"limit": limit}
|
|
157
|
+
if account:
|
|
158
|
+
params["account"] = account
|
|
159
|
+
if status_filter:
|
|
160
|
+
params["status"] = status_filter
|
|
161
|
+
if include_archived:
|
|
162
|
+
params["include_archived"] = "true"
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
r = httpx.get(f"{api}/api/tasks", params=params, timeout=10)
|
|
166
|
+
except httpx.RequestError as e:
|
|
167
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
168
|
+
|
|
169
|
+
if r.status_code != 200:
|
|
170
|
+
raise click.ClickException(f"获取任务列表失败(HTTP {r.status_code})")
|
|
171
|
+
|
|
172
|
+
tasks = r.json()
|
|
173
|
+
if not tasks:
|
|
174
|
+
click.secho("⚠ 暂无任务记录", fg="yellow")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
click.echo()
|
|
178
|
+
click.echo(" ── 任务列表 " + "─" * 62)
|
|
179
|
+
click.echo(f" {'任务 ID':<22} {'状态':<10} {'账号':<18} {'类型':<8} 任务描述")
|
|
180
|
+
click.echo(" " + "─" * 80)
|
|
181
|
+
|
|
182
|
+
for t in tasks:
|
|
183
|
+
color, label = _STATUS_STYLE.get(t["status"], ("white", t["status"]))
|
|
184
|
+
prompt = t["prompt"]
|
|
185
|
+
if len(prompt) > 38:
|
|
186
|
+
prompt = prompt[:38] + "…"
|
|
187
|
+
status_str = click.style(f"{label:<10}", fg=color)
|
|
188
|
+
click.echo(f" {t['id']:<22} {status_str} {t['account']:<18} {t['type']:<8} {prompt}")
|
|
189
|
+
|
|
190
|
+
click.echo()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── task status ───────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
@task_group.command("status")
|
|
196
|
+
@click.argument("task_id")
|
|
197
|
+
def cmd_task_status(task_id: str) -> None:
|
|
198
|
+
"""Show task details."""
|
|
199
|
+
api = _require_server()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
r = httpx.get(f"{api}/api/tasks/{task_id}", timeout=10)
|
|
203
|
+
except httpx.RequestError as e:
|
|
204
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
205
|
+
|
|
206
|
+
if r.status_code == 404:
|
|
207
|
+
raise click.ClickException(f"任务 '{task_id}' 不存在")
|
|
208
|
+
if r.status_code != 200:
|
|
209
|
+
raise click.ClickException(f"查询失败(HTTP {r.status_code})")
|
|
210
|
+
|
|
211
|
+
d = r.json()
|
|
212
|
+
color, label = _STATUS_STYLE.get(d["status"], ("white", d["status"]))
|
|
213
|
+
|
|
214
|
+
click.echo()
|
|
215
|
+
click.echo(f" ID: {d['id']}")
|
|
216
|
+
click.echo(f" 状态: {click.style(label, fg=color)}")
|
|
217
|
+
click.echo(f" 账号: {d['account']} ({d['type']})")
|
|
218
|
+
click.echo(f" 项目: {d['project']}")
|
|
219
|
+
if d.get("project_name"):
|
|
220
|
+
click.echo(f" 项目名: {d['project_name']}")
|
|
221
|
+
click.echo(f" 创建: {d['created']}")
|
|
222
|
+
click.echo(f" 完成: {d.get('finished') or '-'}")
|
|
223
|
+
if d.get("execute_at"):
|
|
224
|
+
click.echo(f" 定时: {d['execute_at']}")
|
|
225
|
+
if d.get("conversation_id"):
|
|
226
|
+
click.echo(f" 任务链: {d['conversation_id']}")
|
|
227
|
+
if d.get("native_session_id"):
|
|
228
|
+
click.echo(f" 会话ID: {d['native_session_id']}")
|
|
229
|
+
click.echo(f" 任务描述:{d['prompt']}")
|
|
230
|
+
click.echo()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── task logs ─────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
@task_group.command("logs")
|
|
236
|
+
@click.argument("task_id")
|
|
237
|
+
@click.option("-f", "--follow", is_flag=True, help="实时跟踪日志(流式输出)")
|
|
238
|
+
def cmd_task_logs(task_id: str, follow: bool) -> None:
|
|
239
|
+
"""Show task logs. Use -f to stream in real time."""
|
|
240
|
+
api = _require_server()
|
|
241
|
+
|
|
242
|
+
if follow:
|
|
243
|
+
click.secho("实时跟踪日志(Ctrl+C 退出)...", dim=True)
|
|
244
|
+
url = f"{api}/api/tasks/{task_id}/logs/stream?tail=80"
|
|
245
|
+
try:
|
|
246
|
+
with httpx.stream("GET", url, timeout=None) as r:
|
|
247
|
+
if r.status_code == 404:
|
|
248
|
+
raise click.ClickException(f"任务 '{task_id}' 不存在")
|
|
249
|
+
for line in r.iter_lines():
|
|
250
|
+
if line == "data: [DONE]":
|
|
251
|
+
break
|
|
252
|
+
if line.startswith("data: "):
|
|
253
|
+
click.echo(line[6:])
|
|
254
|
+
except KeyboardInterrupt:
|
|
255
|
+
pass
|
|
256
|
+
except httpx.RequestError as e:
|
|
257
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
258
|
+
else:
|
|
259
|
+
try:
|
|
260
|
+
r = httpx.get(f"{api}/api/tasks/{task_id}/logs", timeout=30)
|
|
261
|
+
except httpx.RequestError as e:
|
|
262
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
263
|
+
|
|
264
|
+
if r.status_code == 404:
|
|
265
|
+
raise click.ClickException(f"任务 '{task_id}' 的日志不存在")
|
|
266
|
+
if r.status_code != 200:
|
|
267
|
+
raise click.ClickException(f"获取日志失败(HTTP {r.status_code})")
|
|
268
|
+
|
|
269
|
+
click.echo(r.text, nl=False)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── task kill ─────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
@task_group.command("kill")
|
|
275
|
+
@click.argument("task_id")
|
|
276
|
+
def cmd_task_kill(task_id: str) -> None:
|
|
277
|
+
"""Kill a running or pending task."""
|
|
278
|
+
api = _require_server()
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
r = httpx.delete(f"{api}/api/tasks/{task_id}", timeout=10)
|
|
282
|
+
except httpx.RequestError as e:
|
|
283
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
284
|
+
|
|
285
|
+
if r.status_code == 200:
|
|
286
|
+
click.secho(f"✓ 已终止任务 {task_id}", fg="green")
|
|
287
|
+
elif r.status_code == 404:
|
|
288
|
+
raise click.ClickException(f"任务 '{task_id}' 不存在")
|
|
289
|
+
else:
|
|
290
|
+
detail = r.json().get("detail", r.text) if "json" in r.headers.get("content-type", "") else r.text
|
|
291
|
+
raise click.ClickException(f"终止失败(HTTP {r.status_code}):{detail}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── task clean ────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
@task_group.command("clean")
|
|
297
|
+
@click.argument("keep", default=30, required=False)
|
|
298
|
+
def cmd_task_clean(keep: int) -> None:
|
|
299
|
+
"""Clean old task records, keeping the N most recent (default: 30)."""
|
|
300
|
+
api = _require_server()
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
r = httpx.post(f"{api}/api/tasks/clean", params={"keep": keep}, timeout=10)
|
|
304
|
+
except httpx.RequestError as e:
|
|
305
|
+
raise click.ClickException(f"请求失败:{e}")
|
|
306
|
+
|
|
307
|
+
if r.status_code != 200:
|
|
308
|
+
raise click.ClickException(f"清理失败(HTTP {r.status_code})")
|
|
309
|
+
|
|
310
|
+
d = r.json()
|
|
311
|
+
click.secho(f"✓ 已清理 {d['cleaned']} 条历史记录(保留 {d['kept']} 条)", fg="green")
|