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,546 @@
1
+ """
2
+ main.py — CoderFleet 调度服务入口
3
+
4
+ API 路由:
5
+ GET /api/accounts 列出所有账号及状态
6
+ POST /api/tasks 提交任务
7
+ GET /api/tasks 列出所有任务
8
+ GET /api/tasks/{id} 查看任务详情
9
+ DELETE /api/tasks/{id} 终止任务
10
+ GET /api/tasks/{id}/logs 获取完整日志(文本)
11
+ GET /api/tasks/{id}/logs/stream SSE 实时日志流
12
+ POST /api/tasks/clean 清理旧任务记录
13
+ GET /api/health 健康检查
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import AsyncIterator, Optional
23
+ from urllib.parse import urlparse
24
+
25
+ import uuid
26
+
27
+ import aiofiles
28
+ from fastapi import FastAPI, File, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
29
+ from fastapi.middleware.cors import CORSMiddleware
30
+ from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
31
+ from fastapi.staticfiles import StaticFiles
32
+ from pydantic import BaseModel
33
+
34
+ from coderfleet.server.models import (
35
+ AccountResponse,
36
+ AccountType,
37
+ Conversation,
38
+ ConversationResponse,
39
+ ConversationStatus,
40
+ ProjectResponse,
41
+ Task,
42
+ TaskCreateRequest,
43
+ TaskResponse,
44
+ TaskStatus,
45
+ )
46
+ from coderfleet.server.scheduler import Scheduler
47
+ from coderfleet.server.terminal import TerminalSession, resolve_terminal_target
48
+
49
+
50
+ class ConversationCreateRequest(BaseModel):
51
+ """从已有任务创建任务链的请求体"""
52
+ name: str
53
+ task_id: str # 用该任务的 native_session_id 初始化任务链
54
+
55
+ # ── 初始化 ────────────────────────────────────────────────
56
+
57
+ WORKSPACE_DIR = Path(os.environ.get("CODERFLEET_WORKSPACE", Path.home() / ".coderfleet"))
58
+ scheduler = Scheduler(WORKSPACE_DIR)
59
+
60
+ STATIC_DIR = Path(__file__).parent / "static"
61
+ STATIC_DIR.mkdir(exist_ok=True)
62
+
63
+ app = FastAPI(
64
+ title = "CoderFleet Scheduler API",
65
+ description = "CoderFleet 任务调度服务",
66
+ version = "0.1.0",
67
+ )
68
+
69
+ app.add_middleware(
70
+ CORSMiddleware,
71
+ allow_origins = ["*"],
72
+ allow_credentials = True,
73
+ allow_methods = ["*"],
74
+ allow_headers = ["*"],
75
+ )
76
+
77
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
78
+
79
+
80
+ @app.on_event("startup")
81
+ async def reconcile_tasks_on_startup():
82
+ await scheduler.reconcile_running_tasks()
83
+ scheduler.start_scheduling_loop()
84
+
85
+
86
+ @app.get("/", include_in_schema=False)
87
+ async def index():
88
+ html = STATIC_DIR / "index.html"
89
+ if not html.exists():
90
+ return PlainTextResponse("Web UI not found.", status_code=404)
91
+ return FileResponse(html)
92
+
93
+
94
+ # ── 健康检查 ──────────────────────────────────────────────
95
+
96
+ @app.get("/api/health")
97
+ async def health():
98
+ return {"status": "ok", "workspace": str(WORKSPACE_DIR)}
99
+
100
+
101
+ # ── 账号 ──────────────────────────────────────────────────
102
+
103
+ @app.get("/api/accounts", response_model=list[AccountResponse])
104
+ async def list_accounts():
105
+ """列出所有账号,包含容器状态和忙碌状态"""
106
+ return scheduler.list_accounts()
107
+
108
+
109
+ # ── 任务提交 ──────────────────────────────────────────────
110
+
111
+ @app.post("/api/tasks", response_model=TaskResponse, status_code=201)
112
+ async def create_task(req: TaskCreateRequest):
113
+ """
114
+ 提交任务,立即返回任务对象(异步执行)。
115
+
116
+ 匹配优先级:
117
+ 1. account 指定 → 直接用该账号
118
+ 2. project 指定 → 找挂载该路径的空闲账号
119
+ 3. type 指定 → 找对应类型的空闲账号
120
+ 4. 都不指定 → 找第一个空闲账号
121
+ """
122
+ try:
123
+ task = await scheduler.submit(
124
+ prompt = req.prompt,
125
+ account_name = req.account,
126
+ prefer_project = req.project,
127
+ prefer_type = req.type,
128
+ auto = req.auto,
129
+ conversation_id = req.conversation_id,
130
+ conversation_name = req.conversation_name,
131
+ project_name = req.project_name,
132
+ images = req.images,
133
+ execute_at = req.execute_at,
134
+ )
135
+ except ValueError as e:
136
+ raise HTTPException(status_code=404, detail=str(e))
137
+ except RuntimeError as e:
138
+ raise HTTPException(status_code=409, detail=str(e))
139
+
140
+ return TaskResponse.from_task(task)
141
+
142
+
143
+ @app.get("/api/projects", response_model=list[ProjectResponse])
144
+ async def list_projects():
145
+ return [
146
+ ProjectResponse.from_project(p)
147
+ for p in scheduler.list_projects()
148
+ ]
149
+
150
+
151
+ # ── 图片上传 ──────────────────────────────────────────────
152
+
153
+ _ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
154
+
155
+
156
+ @app.post("/api/uploads")
157
+ async def upload_image(
158
+ file: UploadFile = File(...),
159
+ project_name: str = Query(..., description="项目名称"),
160
+ ):
161
+ """上传图片到项目工作目录,返回容器内可访问的路径。"""
162
+ project = scheduler.find_project_by_name(project_name)
163
+ if project is None:
164
+ raise HTTPException(status_code=404, detail=f"项目 '{project_name}' 不存在")
165
+
166
+ original_name = file.filename or "upload"
167
+ ext = Path(original_name).suffix.lower()
168
+ if ext not in _ALLOWED_IMAGE_EXTS:
169
+ raise HTTPException(status_code=400, detail=f"不支持的图片格式:{ext}")
170
+
171
+ upload_dir = Path(project.path) / ".coderfleet-uploads"
172
+ upload_dir.mkdir(parents=True, exist_ok=True)
173
+
174
+ file_id = uuid.uuid4().hex[:16]
175
+ filename = f"{file_id}{ext}"
176
+ save_path = upload_dir / filename
177
+
178
+ content = await file.read()
179
+ save_path.write_bytes(content)
180
+
181
+ return {
182
+ "container_path": f"/workspace/.coderfleet-uploads/{filename}",
183
+ "preview_url": f"/api/uploads/{project_name}/{filename}",
184
+ "filename": original_name,
185
+ }
186
+
187
+
188
+ @app.get("/api/uploads/{project_name}/{filename}")
189
+ async def serve_upload(project_name: str, filename: str):
190
+ """预览已上传的图片。"""
191
+ project = scheduler.find_project_by_name(project_name)
192
+ if project is None:
193
+ raise HTTPException(status_code=404, detail="项目不存在")
194
+
195
+ safe_name = Path(filename).name
196
+ file_path = Path(project.path) / ".coderfleet-uploads" / safe_name
197
+ if not file_path.exists():
198
+ raise HTTPException(status_code=404, detail="文件不存在")
199
+
200
+ return FileResponse(file_path)
201
+
202
+
203
+ # ── 项目终端 ──────────────────────────────────────────────
204
+
205
+ def _is_allowed_terminal_origin(origin: str | None, host: str | None) -> bool:
206
+ if not origin:
207
+ return True
208
+ parsed = urlparse(origin)
209
+ origin_host = parsed.hostname or ""
210
+ request_host = (host or "").split(":", 1)[0]
211
+ return origin_host in {"localhost", "127.0.0.1", "::1", request_host}
212
+
213
+
214
+ @app.websocket("/api/projects/{project_name}/terminal")
215
+ async def project_terminal(websocket: WebSocket, project_name: str):
216
+ if not _is_allowed_terminal_origin(
217
+ websocket.headers.get("origin"),
218
+ websocket.headers.get("host"),
219
+ ):
220
+ await websocket.close(code=1008)
221
+ return
222
+
223
+ await websocket.accept()
224
+ session: TerminalSession | None = None
225
+
226
+ try:
227
+ try:
228
+ target = resolve_terminal_target(scheduler, project_name)
229
+ except (ValueError, RuntimeError) as e:
230
+ await websocket.send_json({
231
+ "type": "status",
232
+ "state": "error",
233
+ "message": str(e),
234
+ })
235
+ await websocket.close(code=1008)
236
+ return
237
+
238
+ session = TerminalSession(target.command, project_name=target.project.name)
239
+ session.start()
240
+ await websocket.send_json({
241
+ "type": "status",
242
+ "state": "connected",
243
+ "message": f"已连接 {target.container_name}:{target.container_workdir}",
244
+ })
245
+
246
+ async def pump_output() -> None:
247
+ assert session is not None
248
+ while True:
249
+ data = await session.read()
250
+ if data:
251
+ await websocket.send_json({
252
+ "type": "output",
253
+ "data": data.decode("utf-8", errors="replace"),
254
+ })
255
+ else:
256
+ await asyncio.sleep(0.02)
257
+
258
+ async def pump_input() -> None:
259
+ assert session is not None
260
+ while True:
261
+ raw = await websocket.receive_text()
262
+ try:
263
+ message = json.loads(raw)
264
+ except json.JSONDecodeError:
265
+ continue
266
+
267
+ message_type = message.get("type")
268
+ if message_type == "input":
269
+ session.write(str(message.get("data", "")))
270
+ elif message_type == "resize":
271
+ try:
272
+ cols = int(message.get("cols", 0))
273
+ rows = int(message.get("rows", 0))
274
+ except (TypeError, ValueError):
275
+ continue
276
+ session.resize(cols=cols, rows=rows)
277
+
278
+ output_task = asyncio.create_task(pump_output())
279
+ input_task = asyncio.create_task(pump_input())
280
+ done, pending = await asyncio.wait(
281
+ {output_task, input_task},
282
+ return_when=asyncio.FIRST_COMPLETED,
283
+ )
284
+ for task in pending:
285
+ task.cancel()
286
+ for task in done:
287
+ task.result()
288
+ except WebSocketDisconnect:
289
+ pass
290
+ except Exception as e:
291
+ try:
292
+ await websocket.send_json({
293
+ "type": "status",
294
+ "state": "error",
295
+ "message": str(e),
296
+ })
297
+ except Exception:
298
+ pass
299
+ finally:
300
+ if session is not None:
301
+ session.close()
302
+ try:
303
+ await websocket.send_json({
304
+ "type": "status",
305
+ "state": "closed",
306
+ "message": "终端连接已关闭",
307
+ })
308
+ except Exception:
309
+ pass
310
+
311
+
312
+ # ── 任务链 ────────────────────────────────────────────────
313
+
314
+ @app.get("/api/conversations", response_model=list[ConversationResponse])
315
+ async def list_conversations(include_archived: bool = Query(False)):
316
+ return [
317
+ ConversationResponse.from_conversation(c)
318
+ for c in scheduler.list_conversations(include_archived=include_archived)
319
+ ]
320
+
321
+
322
+ @app.post("/api/conversations", response_model=ConversationResponse, status_code=201)
323
+ async def create_conversation(req: ConversationCreateRequest):
324
+ """
325
+ 从已执行过的任务(需有 native_session_id)创建任务链。
326
+ 后续可通过 conversation_id 续接该会话的上下文。
327
+ """
328
+ try:
329
+ conversation = scheduler.create_conversation_from_task(
330
+ name = req.name,
331
+ task_id = req.task_id,
332
+ )
333
+ except ValueError as e:
334
+ raise HTTPException(status_code=400, detail=str(e))
335
+ return ConversationResponse.from_conversation(conversation)
336
+
337
+
338
+ @app.get("/api/conversations/{conversation_id}", response_model=ConversationResponse)
339
+ async def get_conversation(conversation_id: str):
340
+ conversation = scheduler.get_conversation(conversation_id)
341
+ if conversation is None:
342
+ raise HTTPException(status_code=404, detail=f"任务链 '{conversation_id}' 不存在")
343
+ return ConversationResponse.from_conversation(conversation)
344
+
345
+
346
+ class ConversationStatusUpdate(BaseModel):
347
+ status: ConversationStatus
348
+
349
+
350
+ @app.patch("/api/conversations/{conversation_id}", response_model=ConversationResponse)
351
+ async def update_conversation_status(conversation_id: str, body: ConversationStatusUpdate):
352
+ try:
353
+ conv = scheduler.archive_conversation(conversation_id, body.status)
354
+ except ValueError as e:
355
+ raise HTTPException(status_code=404, detail=str(e))
356
+ return ConversationResponse.from_conversation(conv)
357
+
358
+
359
+ @app.delete("/api/conversations/{conversation_id}", status_code=204)
360
+ async def delete_conversation(conversation_id: str):
361
+ try:
362
+ scheduler.delete_conversation(conversation_id)
363
+ except ValueError as e:
364
+ raise HTTPException(status_code=404, detail=str(e))
365
+
366
+
367
+ # ── 任务列表 ──────────────────────────────────────────────
368
+
369
+ @app.get("/api/tasks", response_model=list[TaskResponse])
370
+ async def list_tasks(
371
+ status: Optional[str] = Query(None, description="按状态过滤:running/done/failed/killed"),
372
+ account: Optional[str] = Query(None, description="按账号名过滤"),
373
+ limit: int = Query(50, description="返回条数上限"),
374
+ include_archived: bool = Query(False, description="是否包含已归档的任务"),
375
+ ):
376
+ tasks = scheduler.list_tasks()
377
+
378
+ if not include_archived:
379
+ tasks = [t for t in tasks if not getattr(t, "archived", False)]
380
+
381
+ if status:
382
+ try:
383
+ s = TaskStatus(status)
384
+ tasks = [t for t in tasks if t.status == s]
385
+ except ValueError:
386
+ raise HTTPException(status_code=400, detail=f"无效状态值:{status}")
387
+
388
+ if account:
389
+ tasks = [t for t in tasks if t.account == account]
390
+
391
+ return [TaskResponse.from_task(t) for t in tasks[:limit]]
392
+
393
+
394
+ # ── 任务详情 ──────────────────────────────────────────────
395
+
396
+ @app.get("/api/tasks/{task_id}", response_model=TaskResponse)
397
+ async def get_task(task_id: str):
398
+ task = scheduler.get_task(task_id)
399
+ if task is None:
400
+ raise HTTPException(status_code=404, detail=f"任务 '{task_id}' 不存在")
401
+ return TaskResponse.from_task(task)
402
+
403
+
404
+ # ── 终止任务 ──────────────────────────────────────────────
405
+
406
+ @app.delete("/api/tasks/{task_id}", response_model=TaskResponse)
407
+ async def kill_task(task_id: str):
408
+ try:
409
+ task = await scheduler.kill_task(task_id)
410
+ except ValueError as e:
411
+ raise HTTPException(status_code=404, detail=str(e))
412
+ except RuntimeError as e:
413
+ raise HTTPException(status_code=409, detail=str(e))
414
+ return TaskResponse.from_task(task)
415
+
416
+
417
+ class TaskUpdate(BaseModel):
418
+ archived: bool
419
+
420
+
421
+ @app.patch("/api/tasks/{task_id}", response_model=TaskResponse)
422
+ async def update_task(task_id: str, body: TaskUpdate):
423
+ try:
424
+ task = scheduler.archive_task(task_id, body.archived)
425
+ except ValueError as e:
426
+ raise HTTPException(status_code=404, detail=str(e))
427
+ return TaskResponse.from_task(task)
428
+
429
+
430
+ @app.delete("/api/tasks/{task_id}/record", status_code=204)
431
+ async def delete_task_record(task_id: str):
432
+ try:
433
+ scheduler.delete_task(task_id)
434
+ except ValueError as e:
435
+ raise HTTPException(status_code=404, detail=str(e))
436
+ except RuntimeError as e:
437
+ raise HTTPException(status_code=409, detail=str(e))
438
+
439
+
440
+ # ── 完整日志(文本)──────────────────────────────────────
441
+
442
+ @app.get("/api/tasks/{task_id}/logs", response_class=PlainTextResponse)
443
+ async def get_logs(task_id: str):
444
+ log_path = scheduler.get_log_path(task_id)
445
+ if not log_path.exists():
446
+ raise HTTPException(status_code=404, detail=f"日志文件不存在:{task_id}")
447
+ return log_path.read_text(encoding="utf-8")
448
+
449
+
450
+ # ── SSE 实时日志流 ────────────────────────────────────────
451
+
452
+ @app.get("/api/tasks/{task_id}/logs/stream")
453
+ async def stream_logs(
454
+ task_id: str,
455
+ tail: int = Query(50, description="从末尾多少行开始推送"),
456
+ ):
457
+ """
458
+ Server-Sent Events 实时日志流。
459
+
460
+ 协议:
461
+ data: <日志行内容>\n\n
462
+ data: [DONE]\n\n ← 任务结束时发送,客户端可关闭连接
463
+ """
464
+ log_path = scheduler.get_log_path(task_id)
465
+
466
+ async def generate() -> AsyncIterator[str]:
467
+ # 先推送已有的末尾 N 行
468
+ existing_lines: list[str] = []
469
+ if log_path.exists():
470
+ async with aiofiles.open(log_path, encoding="utf-8") as f:
471
+ content = await f.read()
472
+ existing_lines = content.splitlines(keepends=True)
473
+ for line in existing_lines[-tail:]:
474
+ yield f"data: {line.rstrip()}\n\n"
475
+
476
+ # 如果任务已结束,直接结束流
477
+ task = scheduler.get_task(task_id)
478
+ if task is None:
479
+ yield "data: [DONE]\n\n"
480
+ return
481
+
482
+ last_size = log_path.stat().st_size if log_path.exists() else 0
483
+
484
+ while True:
485
+ await asyncio.sleep(0.3)
486
+
487
+ task = scheduler.get_task(task_id)
488
+ if task is None:
489
+ yield "data: [DONE]\n\n"
490
+ return
491
+
492
+ # 如果还在排队或定时中,持续等待其被调度拉起
493
+ if task.status in (TaskStatus.pending, TaskStatus.scheduled):
494
+ continue
495
+
496
+ # 任务是否已结束
497
+ is_done = task.status not in (TaskStatus.running, TaskStatus.pending, TaskStatus.scheduled)
498
+
499
+ if log_path.exists():
500
+ cur_size = log_path.stat().st_size
501
+ if cur_size > last_size:
502
+ async with aiofiles.open(log_path, encoding="utf-8") as f:
503
+ await f.seek(last_size)
504
+ new_content = await f.read()
505
+ last_size = cur_size
506
+ for line in new_content.splitlines(keepends=True):
507
+ yield f"data: {line.rstrip()}\n\n"
508
+
509
+ if is_done:
510
+ yield "data: [DONE]\n\n"
511
+ return
512
+
513
+ # 检查日志文件(running 状态的任务日志可能正在生成)
514
+ task = scheduler.get_task(task_id)
515
+ if task is None:
516
+ raise HTTPException(status_code=404, detail=f"任务 '{task_id}' 不存在")
517
+
518
+ return StreamingResponse(
519
+ generate(),
520
+ media_type = "text/event-stream",
521
+ headers = {
522
+ "Cache-Control": "no-cache",
523
+ "X-Accel-Buffering": "no", # 禁止 nginx 缓冲
524
+ },
525
+ )
526
+
527
+
528
+ # ── 清理旧任务 ────────────────────────────────────────────
529
+
530
+ @app.post("/api/tasks/clean")
531
+ async def clean_tasks(keep: int = Query(30, description="保留最近 N 条记录")):
532
+ cleaned = scheduler.clean_tasks(keep=keep)
533
+ return {"cleaned": cleaned, "kept": keep}
534
+
535
+
536
+ # ── 启动入口 ──────────────────────────────────────────────
537
+
538
+ if __name__ == "__main__":
539
+ import uvicorn
540
+ uvicorn.run(
541
+ "coderfleet.server.main:app",
542
+ host = "0.0.0.0",
543
+ port = int(os.environ.get("CODERFLEET_PORT", 8765)),
544
+ reload = False,
545
+ workers = 1, # 单进程,scheduler 状态在内存里
546
+ )