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.
Files changed (45) hide show
  1. coderfleet/__init__.py +1 -0
  2. coderfleet/__main__.py +4 -0
  3. coderfleet/cli.py +212 -0
  4. coderfleet/compose.py +176 -0
  5. coderfleet/config.py +69 -0
  6. coderfleet/config_cmds.py +243 -0
  7. coderfleet/data/Dockerfile +92 -0
  8. coderfleet/data/__init__.py +0 -0
  9. coderfleet/data/accounts.conf.example +26 -0
  10. coderfleet/data/config.conf.example +31 -0
  11. coderfleet/data/entrypoint.sh +56 -0
  12. coderfleet/data/projects.conf.example +17 -0
  13. coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
  14. coderfleet/docker_ops.py +385 -0
  15. coderfleet/init_wizard.py +227 -0
  16. coderfleet/login_cmd.py +168 -0
  17. coderfleet/server/__init__.py +0 -0
  18. coderfleet/server/docker_mgr.py +45 -0
  19. coderfleet/server/main.py +546 -0
  20. coderfleet/server/models.py +285 -0
  21. coderfleet/server/scheduler.py +1219 -0
  22. coderfleet/server/static/css/main.css +2906 -0
  23. coderfleet/server/static/index.html +378 -0
  24. coderfleet/server/static/js/accounts.js +85 -0
  25. coderfleet/server/static/js/app.js +28 -0
  26. coderfleet/server/static/js/chat.js +743 -0
  27. coderfleet/server/static/js/log.js +145 -0
  28. coderfleet/server/static/js/nav.js +46 -0
  29. coderfleet/server/static/js/projects.js +298 -0
  30. coderfleet/server/static/js/renderer.js +586 -0
  31. coderfleet/server/static/js/state.js +76 -0
  32. coderfleet/server/static/js/submit.js +200 -0
  33. coderfleet/server/static/js/tasks.js +92 -0
  34. coderfleet/server/static/js/terminal.js +347 -0
  35. coderfleet/server/static/js/utils.js +147 -0
  36. coderfleet/server/static/vendor/marked.min.js +6 -0
  37. coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
  38. coderfleet/server/static/vendor/xterm/xterm.css +218 -0
  39. coderfleet/server/static/vendor/xterm/xterm.js +2 -0
  40. coderfleet/server/terminal.py +129 -0
  41. coderfleet/task_cmds.py +311 -0
  42. coderfleet-0.1.0.dist-info/METADATA +492 -0
  43. coderfleet-0.1.0.dist-info/RECORD +45 -0
  44. coderfleet-0.1.0.dist-info/WHEEL +4 -0
  45. 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
@@ -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")