codebee 0.1.6 → 0.1.7

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.
package/app/main.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import argparse
9
9
  import json
10
+ import logging
10
11
  import os
11
12
  import re
12
13
  import socket
@@ -23,6 +24,8 @@ from core import automation, catalog, flows, jobs, manager, market, market_remot
23
24
  from core import paths
24
25
  from core import health
25
26
 
27
+ log = logging.getLogger(__name__)
28
+
26
29
  MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
27
30
  ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
28
31
  ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
@@ -48,6 +51,23 @@ def _utf8_bytes(data):
48
51
 
49
52
  PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
50
53
 
54
+ # 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
55
+ # 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
56
+ # 独立上传接口)。
57
+ MAX_BODY_BYTES = 16 * 1024 * 1024
58
+ # 附件本体用 base64 包装:24 MiB Office 文件编码后约 32 MiB,再留少量 JSON
59
+ # 开销。该上限仍会在 attachments.save_pending 中按扩展名再次精确校验。
60
+ MAX_ATTACHMENT_BODY_BYTES = 34 * 1024 * 1024
61
+ _BODY_UNSET = object()
62
+
63
+
64
+ class RequestBodyError(ValueError):
65
+ """客户端请求体不可解析;status 用于把错误稳定映射为 400/413。"""
66
+
67
+ def __init__(self, message, status=400):
68
+ super().__init__(message)
69
+ self.status = status
70
+
51
71
  # 侧栏「查看文件」/「目录浏览」跳过的噪音目录(与 store 的习惯一致)
52
72
  _SKIP_DIRS_SHARE = {".git", "node_modules", "__pycache__", ".venv", "venv",
53
73
  ".idea", ".vscode", "_attachments"}
@@ -79,13 +99,38 @@ class Handler(BaseHTTPRequestHandler):
79
99
  self._send(code, json.dumps(obj, ensure_ascii=False))
80
100
 
81
101
  def _body(self):
82
- try:
83
- n = int(self.headers.get("Content-Length") or 0)
84
- if n <= 0:
85
- return {}
86
- return json.loads(self.rfile.read(n).decode("utf-8"))
87
- except Exception:
88
- return {}
102
+ cached = getattr(self, "_parsed_body", _BODY_UNSET)
103
+ if cached is not _BODY_UNSET:
104
+ return cached
105
+ raw_len = self.headers.get("Content-Length")
106
+ if not raw_len:
107
+ body = {}
108
+ else:
109
+ try:
110
+ n = int(raw_len)
111
+ except (TypeError, ValueError):
112
+ raise RequestBodyError("Content-Length 无效")
113
+ if n < 0:
114
+ raise RequestBodyError("Content-Length 无效")
115
+ limit = getattr(self, "_body_limit", MAX_BODY_BYTES)
116
+ if n > limit:
117
+ raise RequestBodyError("请求体过大(最大 %d MiB)" % (limit // (1024 * 1024)), 413)
118
+ if n == 0:
119
+ body = {}
120
+ else:
121
+ data = self.rfile.read(n)
122
+ try:
123
+ text = data.decode("utf-8")
124
+ except UnicodeDecodeError:
125
+ raise RequestBodyError("请求体必须使用 UTF-8 编码")
126
+ try:
127
+ body = json.loads(text)
128
+ except (TypeError, ValueError):
129
+ raise RequestBodyError("请求体不是有效的 JSON")
130
+ if not isinstance(body, dict):
131
+ raise RequestBodyError("JSON 请求体顶层必须是对象")
132
+ self._parsed_body = body
133
+ return body
89
134
 
90
135
  # ------------------------------------------------------------ 远程访问
91
136
  def _forwarded_ip(self):
@@ -210,6 +255,15 @@ class Handler(BaseHTTPRequestHandler):
210
255
  except ValueError:
211
256
  days = 30
212
257
  return self._json(200, usage.summary(days=days))
258
+ if path == "/api/usage/estimate":
259
+ from core import usage
260
+ q = parse_qs(urlparse(self.path).query)
261
+ ttype = (q.get("type") or [""])[0][:32]
262
+ try:
263
+ edays = max(1, min(3650, int((q.get("days") or ["90"])[0])))
264
+ except ValueError:
265
+ edays = 90
266
+ return self._json(200, usage.estimate(task_type=ttype, days=edays))
213
267
  m = re.match(r"^/api/runs/([^/]+)$", path)
214
268
  if m:
215
269
  run = store.get_run(m.group(1))
@@ -366,6 +420,14 @@ class Handler(BaseHTTPRequestHandler):
366
420
  m = None
367
421
  if not self._authed():
368
422
  return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
423
+ # 所有写接口共用一次严格解析;后续路由再次调用 _body() 时直接取缓存。
424
+ # 这样 malformed JSON 不会被当成空对象继续执行,也避免同一请求重复读流。
425
+ self._body_limit = (MAX_ATTACHMENT_BODY_BYTES if path == "/api/attachments"
426
+ else MAX_BODY_BYTES)
427
+ try:
428
+ self._body()
429
+ except RequestBodyError as e:
430
+ return self._json(e.status, {"error": str(e)})
369
431
  if path == "/api/control":
370
432
  return self._api_control()
371
433
  if path == "/api/control/heartbeat":
@@ -416,7 +478,11 @@ class Handler(BaseHTTPRequestHandler):
416
478
  ok, err, run = store.retry_task(m.group(1))
417
479
  if not ok:
418
480
  return self._json(400, {"error": err})
419
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
481
+ queued, qerr = self._enqueue_run(
482
+ run["id"], m.group(1),
483
+ {"kind": "orchestration", "run_id": run["id"], "task_id": m.group(1)})
484
+ if not queued:
485
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
420
486
  return self._json(200, {"ok": True, "run_id": run["id"]})
421
487
  elif m.group(2) == "continue":
422
488
  # 继续连载:在旧任务基础上新建任务(沿用目标/目录/评审设置,章节号衔接)
@@ -424,11 +490,36 @@ class Handler(BaseHTTPRequestHandler):
424
490
  m.group(1), (self._body() or {}).get("chapters"))
425
491
  if not ok:
426
492
  return self._json(400, {"error": err})
427
- run = store.create_run("orchestration", new_task["title"],
428
- task_id=new_task["id"])
429
- store.update_task_status(new_task["id"], "queued")
430
- jobs.enqueue({"kind": "orchestration",
431
- "run_id": run["id"], "task_id": new_task["id"]})
493
+ run = None
494
+ try:
495
+ run = store.create_run("orchestration", new_task["title"],
496
+ task_id=new_task["id"])
497
+ store.update_task_status(new_task["id"], "queued")
498
+ except Exception:
499
+ # continue_task has already persisted the new task. If run
500
+ # initialization fails, close whichever records exist so a
501
+ # retry is possible and no task remains queued forever.
502
+ log.exception("续写运行初始化失败 task=%s run=%s",
503
+ new_task.get("id"), (run or {}).get("id"))
504
+ if run:
505
+ try:
506
+ store.update_run(
507
+ run["id"], status="failed",
508
+ error="运行记录创建失败,请稍后重试",
509
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
510
+ except Exception:
511
+ log.exception("续写运行失败收口失败 run=%s", run.get("id"))
512
+ try:
513
+ store.update_task_status(new_task["id"], "failed")
514
+ except Exception:
515
+ log.exception("续写任务失败收口失败 task=%s", new_task.get("id"))
516
+ return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
517
+ queued, qerr = self._enqueue_run(
518
+ run["id"], new_task["id"],
519
+ {"kind": "orchestration",
520
+ "run_id": run["id"], "task_id": new_task["id"]})
521
+ if not queued:
522
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
432
523
  return self._json(200, {"ok": True, "task_id": new_task["id"],
433
524
  "run_id": run["id"]})
434
525
  elif m.group(2) == "rename":
@@ -508,7 +599,11 @@ class Handler(BaseHTTPRequestHandler):
508
599
  "checking": manager.updates_checking()})
509
600
  if path == "/api/selfupdate/apply":
510
601
  from core import selfupdate
511
- res = selfupdate.apply_upgrade()
602
+ try:
603
+ res = selfupdate.apply_upgrade()
604
+ except Exception:
605
+ log.exception("自更新任务创建失败")
606
+ return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
512
607
  return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
513
608
  if path == "/api/selfupdate/restart":
514
609
  from core import selfupdate
@@ -709,7 +804,11 @@ class Handler(BaseHTTPRequestHandler):
709
804
  % entry["id"]})
710
805
  run = store.create_run("mgmt", "%s %s" % (titles[op], entry.get("name", entry["id"])),
711
806
  entry_id=entry["id"], op=op)
712
- jobs.enqueue({"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
807
+ queued, qerr = self._enqueue_run(
808
+ run["id"], None,
809
+ {"kind": "mgmt", "run_id": run["id"], "entry_id": entry["id"], "op": op})
810
+ if not queued:
811
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
713
812
  return self._json(200, {"run_id": run["id"]})
714
813
  m = re.match(r"^/api/catalog/([^/]+)/launch$", path)
715
814
  if m:
@@ -1202,11 +1301,13 @@ class Handler(BaseHTTPRequestHandler):
1202
1301
  "id": m.get("id"),
1203
1302
  })
1204
1303
 
1205
- def _emit_step(s):
1304
+ def _emit_step(s, run_id_of_step):
1206
1305
  # 正文只认 output(runner 抽好的最终回答)→ summary。绝不回退读原始
1207
1306
  # 日志:日志是全量事件流(下发提示词回显 + CLI 报错),塞进气泡就成了
1208
1307
  # 「看日志」(2026-09-17 实测症状);运行中的步骤两者都还没有,留空给
1209
- # 前端显示「正在执行」占位。
1308
+ # 前端显示「正在执行」占位。log/run 随项下发:气泡上「执行过程」入口
1309
+ # 要按归属 run 打开该步日志(时间线是任务级跨 run 回放,不能拿当前
1310
+ # run id 想当然)。
1210
1311
  body = s.get("output") or ""
1211
1312
  if not body:
1212
1313
  body = s.get("summary") or ""
@@ -1219,6 +1320,8 @@ class Handler(BaseHTTPRequestHandler):
1219
1320
  "status": s.get("status") or "",
1220
1321
  "text": body,
1221
1322
  "note": s.get("note") or "",
1323
+ "run": run_id_of_step,
1324
+ "log": s.get("log") or "",
1222
1325
  })
1223
1326
 
1224
1327
  for r in runs:
@@ -1233,19 +1336,58 @@ class Handler(BaseHTTPRequestHandler):
1233
1336
  _emit_msg(m)
1234
1337
  for s in steps:
1235
1338
  if (s.get("status") or "") == "done":
1236
- _emit_step(s)
1339
+ _emit_step(s, r.get("id") or run_id)
1237
1340
  for m in msgs:
1238
1341
  if not m.get("consumed"):
1239
1342
  _emit_msg(m)
1240
1343
  for s in steps:
1241
1344
  if (s.get("status") or "") != "done":
1242
- _emit_step(s)
1345
+ _emit_step(s, r.get("id") or run_id)
1243
1346
  return self._json(200, {
1244
1347
  "run_id": run_id, "status": run.get("status") or "",
1245
1348
  "engine": engine,
1246
1349
  "items": items,
1350
+ "result": self._direct_result(runs[-1] if runs else run, engine),
1247
1351
  })
1248
1352
 
1353
+ def _direct_result(self, latest, engine):
1354
+ """对话页「执行结果」卡的数据:最新 run 到终态后给出确定性摘要——
1355
+ 成没成、跑多久、谁执行的、产出了哪些文件。模型最后一轮回答可能只是
1356
+ 寒暄/追问(用户反馈:输入"1"跑完 55 秒只见一句"消息可能发错了"),
1357
+ 执行结果不能依赖模型自觉交代,由产品明示。"""
1358
+ if engine != "direct" or not latest:
1359
+ return None
1360
+ st = latest.get("status") or ""
1361
+ if st not in ("done", "failed", "cancelled", "timeout"):
1362
+ return None
1363
+ verdict = latest.get("verdict") or {}
1364
+ route = latest.get("route") or {}
1365
+
1366
+ def _sec(a, b):
1367
+ try:
1368
+ return max(0, int(time.mktime(time.strptime(b, "%Y-%m-%d %H:%M:%S")) -
1369
+ time.mktime(time.strptime(a, "%Y-%m-%d %H:%M:%S"))))
1370
+ except Exception:
1371
+ return None
1372
+ t0 = latest.get("started_at") or latest.get("created_at")
1373
+ wd, files = "", []
1374
+ try:
1375
+ wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
1376
+ if files and not store.task_step_count(latest.get("task_id") or ""):
1377
+ files = [] # 与 /files 端点同口径:无步骤的任务不给成品(fixture 防误报)
1378
+ except Exception:
1379
+ wd, files = "", []
1380
+ return {
1381
+ "status": st,
1382
+ "error": (latest.get("error") or "") if st in ("failed", "timeout") else "",
1383
+ "executor": route.get("implementer") or verdict.get("impl") or "",
1384
+ "turns": verdict.get("turns") or 0,
1385
+ "duration_s": _sec(t0, latest.get("ended_at") or "")
1386
+ if t0 and latest.get("ended_at") else None,
1387
+ "workdir": wd,
1388
+ "files": files,
1389
+ }
1390
+
1249
1391
  def _api_direct_chat(self, run_id):
1250
1392
  """直连对话追话:往已结束的 direct run 追加一条消息并自动续跑。
1251
1393
 
@@ -1282,7 +1424,11 @@ class Handler(BaseHTTPRequestHandler):
1282
1424
  ok, err, new_run = store.retry_task(task["id"])
1283
1425
  if not ok:
1284
1426
  return self._json(400, {"error": err or "无法续跑"})
1285
- jobs.enqueue({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
1427
+ queued, qerr = self._enqueue_run(
1428
+ new_run["id"], task["id"],
1429
+ {"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
1430
+ if not queued:
1431
+ return self._json(503, {"error": qerr, "run_id": new_run["id"]})
1286
1432
  return self._json(200, {"ok": True, "run_id": new_run["id"]})
1287
1433
 
1288
1434
  def _api_retract_message(self, run_id):
@@ -1347,11 +1493,61 @@ class Handler(BaseHTTPRequestHandler):
1347
1493
  task = store.create_task(body)
1348
1494
  except ValueError as e:
1349
1495
  return self._json(400, {"error": str(e)})
1350
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
1351
- store.update_task_status(task["id"], "queued")
1352
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1496
+ run = None
1497
+ try:
1498
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
1499
+ store.update_task_status(task["id"], "queued")
1500
+ except Exception:
1501
+ # create_run 已落盘后,update_task_status 仍可能因磁盘/JSON 错误失败。
1502
+ # 这时必须把已经存在的 run 收口,否则 UI 会永久显示「排队中」。
1503
+ log.exception("创建任务运行记录失败 task=%s run=%s", task.get("id"),
1504
+ (run or {}).get("id"))
1505
+ if run:
1506
+ try:
1507
+ store.update_run(run["id"], status="failed",
1508
+ error="运行记录初始化失败",
1509
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1510
+ except Exception:
1511
+ log.exception("收口失败的运行记录失败 run=%s", run.get("id"))
1512
+ try:
1513
+ store.update_task_status(task["id"], "failed")
1514
+ except Exception:
1515
+ log.exception("收口失败的任务状态失败 task=%s", task.get("id"))
1516
+ return self._json(503, {"error": "运行记录创建失败,请稍后重试"})
1517
+ queued, qerr = self._enqueue_run(
1518
+ run["id"], task["id"],
1519
+ {"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
1520
+ if not queued:
1521
+ return self._json(503, {"error": qerr, "run_id": run["id"]})
1353
1522
  return self._json(200, {"task_id": task["id"], "run_id": run["id"]})
1354
1523
 
1524
+ def _enqueue_run(self, run_id, task_id, job):
1525
+ """入队失败时把已持久化记录收口到 failed,避免 UI 永远显示排队中。"""
1526
+ try:
1527
+ jobs.enqueue(job)
1528
+ return True, ""
1529
+ except Exception:
1530
+ # 不把异常文本(本机路径、命令行参数、供应商响应)返回给客户端;
1531
+ # 详细堆栈只进服务端日志,run 记录也保留稳定的用户可读文案。
1532
+ log.exception("任务入队失败 run=%s task=%s", run_id, task_id)
1533
+ err = "任务入队失败,请稍后重试"
1534
+ try:
1535
+ closed = store.update_run(run_id, status="failed", error=err,
1536
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1537
+ if closed is None:
1538
+ # The run may have been removed between creation and enqueue
1539
+ # (for example, an operator cleared history concurrently).
1540
+ # Still force the task out of queued so the UI cannot wait
1541
+ # forever on a record that no longer exists.
1542
+ raise RuntimeError("运行记录不存在")
1543
+ except Exception:
1544
+ if task_id:
1545
+ try:
1546
+ store.update_task_status(task_id, "failed")
1547
+ except Exception:
1548
+ pass
1549
+ return False, err
1550
+
1355
1551
  def _api_set_preference(self):
1356
1552
  body = self._body()
1357
1553
  agent_id = body.get("agent_id")