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,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
|
+
)
|