codebee 0.1.100 → 0.1.102
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/CHANGELOG.md +17 -0
- package/README.md +8 -5
- package/app/core/acp_client.py +34 -3
- package/app/core/board.py +21 -17
- package/app/core/builtin_agent.py +13 -4
- package/app/core/catalog.py +28 -6
- package/app/core/checkpoints.py +117 -31
- package/app/core/eval_matrix.py +10 -1
- package/app/core/mcp_client.py +7 -2
- package/app/core/openapi.py +8 -0
- package/app/core/pipeline.py +86 -5
- package/app/core/plugins.py +9 -2
- package/app/core/policy.py +9 -0
- package/app/core/publish/auto.py +106 -9
- package/app/core/publish/fanqie.py +16 -10
- package/app/core/publish/flow.py +29 -1
- package/app/core/publish/manager.py +8 -0
- package/app/core/sandbox_runtime.py +95 -0
- package/app/core/store.py +31 -4
- package/app/main.py +1 -0
- package/app/ui/app.js +164 -27
- package/app/ui/hive3d.js +491 -223
- package/app/ui/i18n.js +18 -0
- package/app/ui/index.html +24 -4
- package/app/ui/style.css +548 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.102(2026-10-10)
|
|
8
|
+
|
|
9
|
+
- **蜂巢 3D 蜜蜂办公室全面重建(程序化 WebGL 场景)**:3D 场景从参考贴图升级为全程序化 WebGL 实时渲染——办公室/工位/灯光/蜜蜂形态全部由代码建模并大幅精修(关节手打字、椭圆半透明翅膀、腹部金环纹、人体工学椅、双层光源与接触阴影),静态几何 GPU 批处理+按需渲染更流畅,轨道/平移/复位手感修正,实时遥测投到工位屏幕玻璃上,场景 overlay 支持读屏与键盘;新增**全屏工作台**:参考图/WebGL/2D 三种视图皆可一键全屏(保留画面比例),全屏冒烟测试常驻护航
|
|
10
|
+
- **发布台存草稿四修**:44 章假成功真凶=submit 被 auto_submit 人工闸吞掉而 manager 仍记成功;现以编辑器保存反馈为凭据、起草前 attach-only 对账剔除已发布章、连接瞬断同章自动重试一次、跨批自动发布浏览器互斥(拒起/让位);对账剔除/跳过/重试在发布台进度提示可见
|
|
11
|
+
- **连载超限快照改存 git 对象库**:书稿 manuscript.md 涨破 1MB 快照单文件上限时,评审步曾全灭报「评审模型不可用」伞形文案(增长型悬崖);现超限前态改 `hash-object` 存工作区对象库(≤1MB 仍内嵌),快照/预览/恢复全链路适配并修正大文件恢复指纹;非 git 目录维持拒绝(宁拒不假装可恢复)
|
|
12
|
+
- **大屏实时流对 CLI 步骤回退读日志**:外部 CLI 只落日志文件不写 stream/thinking,连载全 CLI 流程大屏此前永远停在「等待实时输出…」;现回退读步骤日志尾巴(与蜂巢卡同源翻译),跑马灯取实时窗口末行
|
|
13
|
+
- **运行详情步骤行窄主列折行**:模型/供应商徽章把定宽列总和顶到超宽,浏览器缩放/窄窗/检查器挤压主列时必出面板横向滚动条、CLI 日志按钮被切半;≤820px 容器查询自动折行,宽主列单行形态不变,老浏览器优雅降级
|
|
14
|
+
- **命令执行后端可选 Docker 容器**:设置页新增沙箱后端选择(本机默认/Docker 容器);Docker 仅适用于内置命令工具,ACP、外部 CLI 与 MCP 暂不支持容器化、将安全拒绝执行
|
|
15
|
+
|
|
16
|
+
## v0.1.101(2026-10-10)
|
|
17
|
+
|
|
18
|
+
- **重写未达标章带上旧账(连载)**:上一轮评审的 major 意见(章节点名+全书级)随重写提示词下发(`__REDO_ISSUES__` 渲染「上一轮评审未达标原因」块),被 major 点名的过线章一并回炉——总判定卡 major 时只重写未过线章消不掉账的空转实案修复,作者第一遍就知道往哪改
|
|
19
|
+
- **评审口径事前下发作者面(连载)**:单章评审清单、全局关注点、签约专项维度与评审面同源提取(单一真源常量装配,两边永不脱节),经 `__REVIEW_RUBRIC__` 注入起草/修订/打磨提示词——作者开写前就知道按什么标准被审;评分机制本体(分值/阈值)刻意不下发
|
|
20
|
+
- **插件安装失败回滚修复**:回滚标记提前绑定+残缺目标自愈重装;此前标记未绑定把人话错误炸成 500 且跳过残留清理(Mac 装 Auto Retry 撞 429 永久撞车实案)
|
|
21
|
+
- **全局兜底错误行带崩溃现场**:任务异常时 traceback 尾帧(文件:行号:函数)追加进错误字段,UI 错误行直接说清死在哪、不必翻 run 目录日志;同因止损签名按崩溃位置区分,代码升级行号漂移自动允许再试一轮
|
|
22
|
+
- **代码块长行自动换行缺省改为开**:运行详情对话里长 JSON/代码行自动折行,不再撑横向滚动条;设置页「长行自动换行」仍可显式关闭
|
|
23
|
+
|
|
7
24
|
## v0.1.100(2026-10-10)
|
|
8
25
|
|
|
9
26
|
- **ACP 智能体接入(Agent Client Protocol v1)**:设置页新增「ACP Backend Profiles」,可把遵循 ACP v1 的本地/SSH stdio Agent 登记为执行后端(名称/启动命令/参数/工作区),登记后进入智能体注册表、可被编排路由选用;密钥只保存在本机、读取视图始终脱敏
|
package/README.md
CHANGED
|
@@ -25,11 +25,14 @@ Trae Agent、OpenClaw……),提供
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
27
|
<!-- relnotes:start -->
|
|
28
|
-
### 最新版更新内容(v0.1.
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
28
|
+
### 最新版更新内容(v0.1.102)
|
|
29
|
+
|
|
30
|
+
- 蜂巢 3D 蜜蜂办公室全面重建:全程序化 WebGL 实时渲染+大幅视觉精修,参考图/WebGL/2D 三视图皆可一键全屏
|
|
31
|
+
- 发布台存草稿四修:假成功真凶(submit 被人工闸吞掉)根治,保存反馈当凭据+对账剔除+瞬断重试+跨批互斥
|
|
32
|
+
- 连载书稿涨破 1MB 快照上限不再全灭:超限前态改存 git 对象库,恢复链路全适配
|
|
33
|
+
- 大屏实时流对 CLI 步骤回退读日志,全 CLI 连载流程不再永远「等待实时输出」
|
|
34
|
+
- 运行详情窄主列自动折行,缩放/窄窗不再出横向滚动条
|
|
35
|
+
- 命令执行后端可选 Docker 容器(内置命令工具专属)
|
|
33
36
|
<!-- relnotes:end -->
|
|
34
37
|
|
|
35
38
|
|
package/app/core/acp_client.py
CHANGED
|
@@ -15,6 +15,34 @@ from .env_scrub import scrub_env
|
|
|
15
15
|
|
|
16
16
|
_MAX_LINE = 2 * 1024 * 1024
|
|
17
17
|
_MAX_TEXT = 2 * 1024 * 1024
|
|
18
|
+
_MAX_ACP_EVENTS = 200
|
|
19
|
+
_ACP_EVENT_TYPES = {
|
|
20
|
+
"agent_thought_chunk", "tool_call", "tool_call_update", "plan",
|
|
21
|
+
"current_mode_update", "config_option_update", "session_info_update",
|
|
22
|
+
"available_commands_update", "usage_update", "session_notification",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _record_session_update(events, update):
|
|
27
|
+
"""Keep bounded, redacted ACP event metadata without tool payloads."""
|
|
28
|
+
if not isinstance(update, dict) or len(events) >= _MAX_ACP_EVENTS:
|
|
29
|
+
return
|
|
30
|
+
kind = str(update.get("sessionUpdate") or "")[:60]
|
|
31
|
+
if kind not in _ACP_EVENT_TYPES:
|
|
32
|
+
return
|
|
33
|
+
from .redact import scrub_text
|
|
34
|
+
item = {"sessionUpdate": kind}
|
|
35
|
+
for key in ("toolCallId", "title", "status", "modeId", "configId", "commandId"):
|
|
36
|
+
value = update.get(key)
|
|
37
|
+
if isinstance(value, (str, int, float, bool)):
|
|
38
|
+
item[key] = scrub_text(str(value), limit=160)
|
|
39
|
+
entries = update.get("entries")
|
|
40
|
+
if kind == "plan" and isinstance(entries, list):
|
|
41
|
+
item["entries"] = [{
|
|
42
|
+
"content": scrub_text(str(entry.get("content") or ""), limit=160),
|
|
43
|
+
"status": scrub_text(str(entry.get("status") or ""), limit=40),
|
|
44
|
+
} for entry in entries[:12] if isinstance(entry, dict)]
|
|
45
|
+
events.append(item)
|
|
18
46
|
|
|
19
47
|
|
|
20
48
|
class ACPError(Exception):
|
|
@@ -336,6 +364,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
|
|
|
336
364
|
if absolute_deadline is None:
|
|
337
365
|
absolute_deadline = started + max(1.0, float(timeout or 1200))
|
|
338
366
|
text_parts = []
|
|
367
|
+
acp_events = []
|
|
339
368
|
terminals = {}
|
|
340
369
|
session_id = str(resume or "")
|
|
341
370
|
|
|
@@ -344,8 +373,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
|
|
|
344
373
|
if message.get("method") == "session/update":
|
|
345
374
|
params = message.get("params") or {}
|
|
346
375
|
update = params.get("update") or {}
|
|
347
|
-
if params.get("sessionId") == session_id
|
|
348
|
-
|
|
376
|
+
if params.get("sessionId") == session_id:
|
|
377
|
+
_record_session_update(acp_events, update)
|
|
378
|
+
if params.get("sessionId") == session_id and update.get("sessionUpdate") == "agent_message_chunk":
|
|
349
379
|
content = update.get("content") or {}
|
|
350
380
|
if content.get("type") == "text":
|
|
351
381
|
piece = str(content.get("text") or "")
|
|
@@ -395,7 +425,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True, timeout=1200,
|
|
|
395
425
|
return {"ok": True, "text": "".join(text_parts)[:_MAX_TEXT], "json": None,
|
|
396
426
|
"cost_usd": 0.0, "tokens": 0, "usage": None, "error": "",
|
|
397
427
|
"error_code": "", "raw": {"exit_code": 0,
|
|
398
|
-
"duration": time.monotonic() - started
|
|
428
|
+
"duration": time.monotonic() - started,
|
|
429
|
+
"acp_events": acp_events},
|
|
399
430
|
"kind": "acp", "model": None, "sid": session_id,
|
|
400
431
|
"attempts": []}
|
|
401
432
|
except InterruptedError as exc:
|
package/app/core/board.py
CHANGED
|
@@ -66,22 +66,16 @@ def run_detail(run_id):
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def
|
|
70
|
-
"""步骤实时流(正文优先,其次思考)的最后一行,给大屏跑马灯。"""
|
|
71
|
-
for key in ("stream", "thinking"):
|
|
72
|
-
v = step.get(key)
|
|
73
|
-
if isinstance(v, str) and v.strip():
|
|
74
|
-
lines = [ln.strip() for ln in v.strip().splitlines() if ln.strip()]
|
|
75
|
-
if lines:
|
|
76
|
-
return lines[-1][:_TAIL_MAX]
|
|
77
|
-
return ""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _live_block(step):
|
|
69
|
+
def _live_windows(run_id, step):
|
|
81
70
|
"""步骤实时流窗口:尾部多行(正文优先,其次思考),供大屏看它在想什么。
|
|
82
71
|
|
|
83
72
|
返回 (来源标签 code, 文本);标签是 code(output/thinking),由前端 i18n
|
|
84
|
-
|
|
73
|
+
翻译——大屏要做多语言,后端不下发中文。
|
|
74
|
+
|
|
75
|
+
内置智能体步骤边跑边把流写进步骤记录(store.stream_step);外部 CLI 步骤
|
|
76
|
+
只落日志文件、run.json 里没有 stream——此前的连载全 CLI 流程在大屏上永远
|
|
77
|
+
「等待实时输出…」(弹窗日志却有内容,2026-10-10 用户反馈)。故回退读日志
|
|
78
|
+
尾巴:与蜂巢卡 hiveTick 同源同窗口(tail 夹取,不整读大日志)。
|
|
85
79
|
"""
|
|
86
80
|
for key, code in (("stream", "output"), ("thinking", "thinking")):
|
|
87
81
|
v = step.get(key)
|
|
@@ -91,6 +85,14 @@ def _live_block(step):
|
|
|
91
85
|
lines = [ln for ln in lines if ln]
|
|
92
86
|
if lines:
|
|
93
87
|
return code, "\n".join(lines[-_TAIL_LINES:])
|
|
88
|
+
rel = step.get("log")
|
|
89
|
+
if rel and run_id:
|
|
90
|
+
from . import store
|
|
91
|
+
text = store.read_step_log(str(run_id), str(rel), tail=2400, pretty=True)
|
|
92
|
+
lines = [ln.strip()[:_TAIL_LINE_CHARS] for ln in (text or "").splitlines()]
|
|
93
|
+
lines = [ln for ln in lines if ln]
|
|
94
|
+
if lines:
|
|
95
|
+
return "output", "\n".join(lines[-_TAIL_LINES:])
|
|
94
96
|
return "", ""
|
|
95
97
|
|
|
96
98
|
|
|
@@ -129,18 +131,19 @@ def _duration_s(run):
|
|
|
129
131
|
return None
|
|
130
132
|
|
|
131
133
|
|
|
132
|
-
def _step_view(steps, prov_map):
|
|
134
|
+
def _step_view(steps, prov_map, run_id=""):
|
|
133
135
|
"""进度概要:完成数 + 当前运行中步骤(含模型/厂商/实时流)。"""
|
|
134
136
|
done = sum(1 for s in steps if s.get("status") == "done")
|
|
135
137
|
cur = next((s for s in steps if s.get("status") == "running"), None)
|
|
136
138
|
cur_v = None
|
|
137
139
|
if cur:
|
|
138
|
-
live_label, live_text =
|
|
140
|
+
live_label, live_text = _live_windows(run_id, cur)
|
|
139
141
|
model = str(cur.get("model") or "")[:60]
|
|
142
|
+
tail = (live_text.splitlines() or [""])[-1][:_TAIL_MAX] if live_text else ""
|
|
140
143
|
cur_v = {"n": cur.get("n"), "role": cur.get("role") or "",
|
|
141
144
|
"agent": cur.get("agent_label") or cur.get("agent") or "",
|
|
142
145
|
"summary": (cur.get("summary") or "")[:80],
|
|
143
|
-
"tail":
|
|
146
|
+
"tail": tail,
|
|
144
147
|
"model": model, "provider": prov_map.get(model, "") if model else "",
|
|
145
148
|
"live_label": live_label, "live_text": live_text}
|
|
146
149
|
return done, len(steps), cur_v
|
|
@@ -167,7 +170,8 @@ def _build():
|
|
|
167
170
|
run = latest.get(tid)
|
|
168
171
|
status = t.get("status")
|
|
169
172
|
if status == "running" and run and run.get("status") == "running":
|
|
170
|
-
done, total, cur = _step_view(run.get("steps") or [], prov_map
|
|
173
|
+
done, total, cur = _step_view(run.get("steps") or [], prov_map,
|
|
174
|
+
run_id=run.get("id"))
|
|
171
175
|
running.append({
|
|
172
176
|
"task_id": tid, "run_id": run.get("id"),
|
|
173
177
|
"title": t.get("title") or "", "goal": (t.get("goal") or "")[:120],
|
|
@@ -441,13 +441,20 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
|
|
|
441
441
|
# policy keeps the user-approved full-trust tier (2026-09-22) instead of
|
|
442
442
|
# refusing every command.
|
|
443
443
|
has_bwrap = os.name == "posix" and bool(shutil.which("bwrap"))
|
|
444
|
-
if sandbox.get("network") is False and not has_bwrap:
|
|
444
|
+
if sandbox.get("network") is False and not has_bwrap and sandbox.get("backend") != "docker":
|
|
445
445
|
return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
|
|
446
|
-
if sandbox.get("env_allowlist"):
|
|
446
|
+
if sandbox.get("env_allowlist") and sandbox.get("backend") != "docker":
|
|
447
447
|
# run_process 的 env 是注入语义(合并进完整父环境,供凭据注入),
|
|
448
448
|
# 不是白名单替换——环境收窄在子进程侧无法兑现,宁拒不假装
|
|
449
449
|
return "(沙箱拒绝:该任务带环境变量白名单,命令执行无法兑现该策略,未执行)"
|
|
450
|
-
if
|
|
450
|
+
if sandbox.get("backend") == "docker":
|
|
451
|
+
try:
|
|
452
|
+
from .sandbox_runtime import docker_argv, docker_env
|
|
453
|
+
argv = docker_argv(cmdline, sandbox=sandbox, workdir=cwd)
|
|
454
|
+
launch_env = docker_env(sandbox)
|
|
455
|
+
except Exception as exc:
|
|
456
|
+
return "(沙箱拒绝:%s,命令未执行)" % str(exc)[:240]
|
|
457
|
+
elif has_bwrap:
|
|
451
458
|
argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
|
|
452
459
|
# Mount only the runtime needed by ordinary commands. Do not expose the
|
|
453
460
|
# entire host root read-only: read access can still leak user secrets.
|
|
@@ -468,10 +475,12 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
|
|
|
468
475
|
argv.extend(["--dir", root_path])
|
|
469
476
|
argv.extend(["--bind", root_path, root_path])
|
|
470
477
|
argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
|
|
478
|
+
launch_env = None
|
|
471
479
|
else:
|
|
472
480
|
argv = None # 全信任档:runner 按平台自包 cmd /c 或 /bin/sh -c
|
|
481
|
+
launch_env = None
|
|
473
482
|
r = runner.run_process(argv=argv, shell_cmd=cmdline if argv is None else None,
|
|
474
|
-
cwd=cwd, env=
|
|
483
|
+
cwd=cwd, env=launch_env,
|
|
475
484
|
timeout=min(timeout, CMD_MAX_TIMEOUT),
|
|
476
485
|
deadline=deadline, cancel_event=cancel_event)
|
|
477
486
|
out = runner.clean_cli_text(
|
package/app/core/catalog.py
CHANGED
|
@@ -200,15 +200,19 @@ DEFAULT_CATALOG = [
|
|
|
200
200
|
{
|
|
201
201
|
"id": "trae-agent", "name": "Trae Agent", "cli_group": "installable",
|
|
202
202
|
"note": "字节跳动开源 trae-agent;可执行名 trae-cli;PyPI 无包,uv tool 从 "
|
|
203
|
-
"GitHub 直装(Python ≥3.12
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
203
|
+
"GitHub 直装(Python ≥3.12);--with 钉 cryptography<49——49.0.0 起"
|
|
204
|
+
"上游不再发 Intel Mac wheel,缺 brew 的 Mac 落 sdist 编译必死于 "
|
|
205
|
+
"openssl-sys;无头 trae-cli run \"提示词\"(一次性任务形态);配置 "
|
|
206
|
+
"trae_config.yaml 默认找当前工作目录(TRAE_CONFIG_FILE 可指定),"
|
|
207
|
+
"默认模型是 agents.trae_agent.model 指向 models 表别名的两层引用,"
|
|
208
|
+
"CodeBee 暂不托管其模型落盘——绑定模型请在 trae_config.yaml 自配",
|
|
207
209
|
"detect": {"cli": "trae-cli"},
|
|
208
210
|
"orch": {"kind": "generic", "command": "trae-cli", "argv_template": ["run", "{prompt}"]},
|
|
209
211
|
"config": {"path": "~/.trae/trae_config.yaml", "format": None, "model_key": None},
|
|
210
|
-
"install":
|
|
211
|
-
|
|
212
|
+
"install": 'uv tool install --python 3.12 --with "cryptography<49"'
|
|
213
|
+
" git+https://github.com/bytedance/trae-agent",
|
|
214
|
+
"upgrade": 'uv tool install --force --python 3.12 --with "cryptography<49"'
|
|
215
|
+
" git+https://github.com/bytedance/trae-agent",
|
|
212
216
|
"uninstall": "uv tool uninstall trae-agent",
|
|
213
217
|
"default_enabled": False,
|
|
214
218
|
},
|
|
@@ -395,6 +399,24 @@ def _apply_install_patch(entries):
|
|
|
395
399
|
cur = (e.get("install") or "").strip()
|
|
396
400
|
if "pip" in cur and "install" in cur:
|
|
397
401
|
e["install"], e["upgrade"] = aider_uv
|
|
402
|
+
# trae-agent 经 google-genai→google-auth 拉 cryptography,而 cryptography
|
|
403
|
+
# 49.0.0 起不再发 Intel Mac wheel(只剩 macosx_11_0_arm64)——uv 找不到
|
|
404
|
+
# wheel 落 sdist 经 maturin/cargo 编译,openssl-sys 在无 brew 的 Mac 上
|
|
405
|
+
# 缺 pkg-config/OpenSSL 开发头必死(2026-10-10 Mac 实案)。钉 <49(48.x
|
|
406
|
+
# 是最后带 macosx_10_9_universal2 的系列,google-auth 只要求 >=38.0.3)
|
|
407
|
+
# 让 uv 恒走预编译 wheel。凡 uv 形态且未钉 cryptography 的旧命令一律
|
|
408
|
+
# 迁移——钉版是兼容底线不是偏好,trae 的安装命令本就无人手改。
|
|
409
|
+
trae_pinned = ('uv tool install --python 3.12 --with "cryptography<49"'
|
|
410
|
+
" git+https://github.com/bytedance/trae-agent")
|
|
411
|
+
trae_upgrade_pinned = ('uv tool install --force --python 3.12'
|
|
412
|
+
' --with "cryptography<49"'
|
|
413
|
+
" git+https://github.com/bytedance/trae-agent")
|
|
414
|
+
for e in entries:
|
|
415
|
+
if e.get("id") != "trae-agent":
|
|
416
|
+
continue
|
|
417
|
+
cur = (e.get("install") or "").strip()
|
|
418
|
+
if "uv tool install" in cur and "cryptography<" not in cur:
|
|
419
|
+
e["install"], e["upgrade"] = trae_pinned, trae_upgrade_pinned
|
|
398
420
|
if sys.platform == "win32":
|
|
399
421
|
return
|
|
400
422
|
for e in entries:
|
package/app/core/checkpoints.py
CHANGED
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import base64
|
|
8
8
|
import difflib
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import secrets
|
|
11
12
|
import stat
|
|
12
13
|
import subprocess
|
|
@@ -21,7 +22,7 @@ from .redact import scrub_text
|
|
|
21
22
|
_DIR = paths.DATA_DIR / "checkpoints"
|
|
22
23
|
_LOCK = threading.RLock()
|
|
23
24
|
_TERMINAL = {"done", "failed", "cancelled", "timeout", "unknown"}
|
|
24
|
-
_SNAPSHOT_MAX_BYTES = 1024 * 1024
|
|
25
|
+
_SNAPSHOT_MAX_BYTES = 1024 * 1024 # ≤此值内嵌快照 JSON;超限进 git 对象库
|
|
25
26
|
_SNAPSHOT_TOTAL_MAX_BYTES = 16 * 1024 * 1024
|
|
26
27
|
_SUPPORTS_DIR_FD_RENAME = os.rename in getattr(os, "supports_dir_fd", set())
|
|
27
28
|
|
|
@@ -141,7 +142,12 @@ def replay_preview(run_id):
|
|
|
141
142
|
or x.get("step") not in starts), None)
|
|
142
143
|
if not steps:
|
|
143
144
|
unknown = {"reason": "checkpoint_not_found"}
|
|
145
|
+
total_steps = len(steps)
|
|
146
|
+
max_preview_steps = 200
|
|
147
|
+
steps = steps[-max_preview_steps:]
|
|
144
148
|
return {"run_id": str(run_id or ""), "steps": steps,
|
|
149
|
+
"total_steps": total_steps,
|
|
150
|
+
"truncated": total_steps > max_preview_steps,
|
|
145
151
|
"replayable": unknown is None,
|
|
146
152
|
"blocked_reason": ("checkpoint_not_found" if not steps else
|
|
147
153
|
"requires_reconciliation" if unknown else "")}
|
|
@@ -198,15 +204,17 @@ def _load_snapshot(run_id, rel):
|
|
|
198
204
|
def capture_file(run_id, workdir, path):
|
|
199
205
|
"""Persist the first pre-edit bytes for a workspace-relative file.
|
|
200
206
|
|
|
201
|
-
|
|
202
|
-
|
|
207
|
+
≤1MB 内嵌进快照 JSON(b64);更大的文件(连载全书稿这类持续增长的
|
|
208
|
+
tracked 脏文件)前态进工作区 git 对象库,不再顶着 1MB 上限把整步拒死
|
|
209
|
+
(2026-10-10 实案:manuscript.md 涨到 1.14MB 后全部评审步被「已有改动
|
|
210
|
+
无法完整快照」拒起 CLI)。对象库也存不进(非 git 目录)才维持拒绝:
|
|
211
|
+
起跑闸宁拒不假装可恢复。
|
|
203
212
|
"""
|
|
204
213
|
root, rel = _safe_snapshot_path(workdir, path)
|
|
205
214
|
if root is None:
|
|
206
215
|
return {"ok": False, "reason": "path_outside_workspace"}
|
|
207
216
|
with _LOCK:
|
|
208
|
-
|
|
209
|
-
if existing:
|
|
217
|
+
if _load_snapshot(run_id, rel):
|
|
210
218
|
return {"ok": True, "existing": True, "path": rel}
|
|
211
219
|
snap_dir = _snapshot_dir(run_id)
|
|
212
220
|
try:
|
|
@@ -215,28 +223,32 @@ def capture_file(run_id, workdir, path):
|
|
|
215
223
|
target = root / rel
|
|
216
224
|
existed = target.exists()
|
|
217
225
|
raw = target.read_bytes() if existed else b""
|
|
218
|
-
if len(raw) > _SNAPSHOT_MAX_BYTES
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return
|
|
226
|
+
if len(raw) > _SNAPSHOT_MAX_BYTES:
|
|
227
|
+
return _write_oversize_snapshot(run_id, root, rel, existed,
|
|
228
|
+
file_path=target)
|
|
229
|
+
if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
|
|
230
|
+
# b64 预算满:大内容照样可以走对象库,别在这里误杀
|
|
231
|
+
return _write_oversize_snapshot(run_id, root, rel, existed,
|
|
232
|
+
file_path=target) if raw else \
|
|
233
|
+
{"ok": False, "reason": "snapshot_size_limit", "path": rel}
|
|
234
|
+
return _write_snapshot(run_id, rel, existed, raw=raw)
|
|
227
235
|
except OSError as exc:
|
|
228
236
|
return {"ok": False, "reason": "snapshot_io_error", "path": rel,
|
|
229
237
|
"detail": str(exc)[:200]}
|
|
230
238
|
|
|
231
239
|
|
|
232
240
|
def capture_bytes(run_id, workdir, path, raw, *, existed=True):
|
|
233
|
-
"""Persist an explicit pre-step byte image (used for clean Git baseline files).
|
|
241
|
+
"""Persist an explicit pre-step byte image (used for clean Git baseline files).
|
|
242
|
+
|
|
243
|
+
超限字节流同样进 git 对象库兜底:finish 阶段从 HEAD 取回的 clean 基线
|
|
244
|
+
(如归档后的整本 manuscript.md)此前只能给 warning,现在也能完整留底。
|
|
245
|
+
"""
|
|
234
246
|
root, rel = _safe_snapshot_path(workdir, path)
|
|
235
247
|
if root is None:
|
|
236
248
|
return {"ok": False, "reason": "path_outside_workspace"}
|
|
237
249
|
raw = bytes(raw or b"") if existed else b""
|
|
238
250
|
if len(raw) > _SNAPSHOT_MAX_BYTES:
|
|
239
|
-
return
|
|
251
|
+
return _write_oversize_snapshot(run_id, root, rel, existed, raw=raw)
|
|
240
252
|
with _LOCK:
|
|
241
253
|
if _load_snapshot(run_id, rel):
|
|
242
254
|
return {"ok": True, "existing": True, "path": rel}
|
|
@@ -246,27 +258,87 @@ def capture_bytes(run_id, workdir, path, raw, *, existed=True):
|
|
|
246
258
|
total = sum(p.stat().st_size for p in snap_dir.glob("*.json") if p.is_file())
|
|
247
259
|
if total + len(raw) > _SNAPSHOT_TOTAL_MAX_BYTES:
|
|
248
260
|
return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
|
|
249
|
-
|
|
250
|
-
"before_b64": base64.b64encode(raw).decode("ascii"),
|
|
251
|
-
"before_sha256": hashlib.sha256(raw).hexdigest() if existed else "",
|
|
252
|
-
"after_sha256": None, "captured_at": time.time()}
|
|
253
|
-
_snapshot_file(run_id, rel).write_text(
|
|
254
|
-
json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
|
255
|
-
return {"ok": True, "existing": False, "path": rel}
|
|
261
|
+
return _write_snapshot(run_id, rel, existed, raw=raw)
|
|
256
262
|
except OSError as exc:
|
|
257
263
|
return {"ok": False, "reason": "snapshot_io_error", "path": rel,
|
|
258
264
|
"detail": str(exc)[:200]}
|
|
259
265
|
|
|
260
266
|
|
|
261
|
-
def _git_bytes(workdir, *args):
|
|
267
|
+
def _git_bytes(workdir, *args, data=None):
|
|
262
268
|
try:
|
|
263
269
|
return subprocess.run(["git", "-C", str(workdir), *args],
|
|
264
270
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
271
|
+
input=data,
|
|
265
272
|
timeout=15, check=False)
|
|
266
273
|
except (OSError, subprocess.SubprocessError):
|
|
267
274
|
return None
|
|
268
275
|
|
|
269
276
|
|
|
277
|
+
def _git_blob_sha(workdir, *, file_path=None, raw=None):
|
|
278
|
+
"""把内容存进工作区 git 仓库的对象库,返回 blob sha(空串=失败)。
|
|
279
|
+
|
|
280
|
+
超限文件的前态走这里:blob 内容寻址、批间归档提交后自然转为被引用;
|
|
281
|
+
未被引用期间也只在 git gc 的两周宽限后才可能被清,与检查点生命周期同量级。
|
|
282
|
+
"""
|
|
283
|
+
if file_path is not None:
|
|
284
|
+
res = _git_bytes(workdir, "hash-object", "-w", "--", str(file_path))
|
|
285
|
+
else:
|
|
286
|
+
res = _git_bytes(workdir, "hash-object", "-w", "--stdin", data=bytes(raw or b""))
|
|
287
|
+
if not res or res.returncode:
|
|
288
|
+
return ""
|
|
289
|
+
sha = os.fsdecode(res.stdout).strip()
|
|
290
|
+
return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _snapshot_before_bytes(item, root):
|
|
294
|
+
"""快照的前态字节:小文件取 JSON 内嵌 b64,超限文件回对象库取 blob。
|
|
295
|
+
|
|
296
|
+
返回 None = 前态不可得(对象库不可用或已被清理),调用方按不可恢复处理。
|
|
297
|
+
"""
|
|
298
|
+
b64 = item.get("before_b64")
|
|
299
|
+
if b64:
|
|
300
|
+
return base64.b64decode(b64, validate=True)
|
|
301
|
+
sha = item.get("before_git_blob") or ""
|
|
302
|
+
if sha:
|
|
303
|
+
res = _git_bytes(root, "cat-file", "blob", sha)
|
|
304
|
+
if res is not None and not res.returncode:
|
|
305
|
+
return res.stdout
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _write_snapshot(run_id, rel, existed, raw=None, blob_sha=""):
|
|
310
|
+
"""落一份前态快照记录:小文件内嵌 b64,超限文件只记 blob 引用。"""
|
|
311
|
+
item = {"path": rel, "existed": bool(existed),
|
|
312
|
+
"before_sha256": hashlib.sha256(raw or b"").hexdigest() if existed else "",
|
|
313
|
+
"after_sha256": None, "captured_at": time.time()}
|
|
314
|
+
if blob_sha:
|
|
315
|
+
item["before_git_blob"] = blob_sha
|
|
316
|
+
else:
|
|
317
|
+
item["before_b64"] = base64.b64encode(raw or b"").decode("ascii")
|
|
318
|
+
_snapshot_file(run_id, rel).write_text(
|
|
319
|
+
json.dumps(item, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
|
320
|
+
return {"ok": True, "existing": False, "path": rel}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _write_oversize_snapshot(run_id, root, rel, existed, *, file_path=None, raw=None):
|
|
324
|
+
"""超限前态的落盘:存进 git 对象库;存不进(非 git 目录)才维持拒绝。"""
|
|
325
|
+
if file_path is not None:
|
|
326
|
+
sha = _git_blob_sha(root, file_path=file_path)
|
|
327
|
+
else:
|
|
328
|
+
sha = _git_blob_sha(root, raw=raw)
|
|
329
|
+
if not sha:
|
|
330
|
+
return {"ok": False, "reason": "snapshot_size_limit", "path": rel}
|
|
331
|
+
with _LOCK:
|
|
332
|
+
if _load_snapshot(run_id, rel):
|
|
333
|
+
return {"ok": True, "existing": True, "path": rel}
|
|
334
|
+
try:
|
|
335
|
+
_snapshot_dir(run_id).mkdir(parents=True, exist_ok=True)
|
|
336
|
+
return _write_snapshot(run_id, rel, existed, blob_sha=sha)
|
|
337
|
+
except OSError as exc:
|
|
338
|
+
return {"ok": False, "reason": "snapshot_io_error", "path": rel,
|
|
339
|
+
"detail": str(exc)[:200]}
|
|
340
|
+
|
|
341
|
+
|
|
270
342
|
def _git_changed_paths(workdir, gitmod):
|
|
271
343
|
"""Return every changed path, including both sides of renames/copies."""
|
|
272
344
|
result = _git_bytes(workdir, "status", "--porcelain", "-z", "--untracked-files=all")
|
|
@@ -453,7 +525,7 @@ def snapshot_preview(run_id, workdir):
|
|
|
453
525
|
target = root / rel
|
|
454
526
|
current = target.read_bytes() if target.is_file() and not target.is_symlink() else None
|
|
455
527
|
current_hash = hashlib.sha256(current).hexdigest() if current is not None else None
|
|
456
|
-
before =
|
|
528
|
+
before = _snapshot_before_bytes(item, root)
|
|
457
529
|
status = "unchanged" if current_hash == item.get("before_sha256") else "changed"
|
|
458
530
|
if "after_exists" in item:
|
|
459
531
|
can_restore = (bool(item.get("after_exists")) == (current is not None)
|
|
@@ -464,7 +536,9 @@ def snapshot_preview(run_id, workdir):
|
|
|
464
536
|
else:
|
|
465
537
|
can_restore = item.get("after_sha256") == current_hash
|
|
466
538
|
diff = ""
|
|
467
|
-
if
|
|
539
|
+
if before is None and item.get("before_git_blob"):
|
|
540
|
+
diff = "(大文件前态存于 git 对象库且当前不可读取,差异省略)"
|
|
541
|
+
elif current is not None and item.get("existed") and before is not None:
|
|
468
542
|
try:
|
|
469
543
|
old_text = before.decode("utf-8").splitlines(True)
|
|
470
544
|
new_text = current.decode("utf-8").splitlines(True)
|
|
@@ -521,16 +595,26 @@ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
|
|
|
521
595
|
try:
|
|
522
596
|
if not stat.S_ISREG(os.fstat(file_fd).st_mode):
|
|
523
597
|
return False
|
|
524
|
-
|
|
525
|
-
|
|
598
|
+
# 全量流式哈希:截断读会让超限文件(前态走对象库的那种)
|
|
599
|
+
# 的指纹恒不匹配、永远拒恢复。
|
|
600
|
+
digest = hashlib.sha256()
|
|
601
|
+
while True:
|
|
602
|
+
chunk = os.read(file_fd, 1 << 20)
|
|
603
|
+
if not chunk:
|
|
604
|
+
break
|
|
605
|
+
digest.update(chunk)
|
|
606
|
+
current = digest.hexdigest()
|
|
526
607
|
finally:
|
|
527
608
|
os.close(file_fd)
|
|
528
609
|
current_exists = current is not None
|
|
529
|
-
current_hash =
|
|
610
|
+
current_hash = current if current_exists else None
|
|
530
611
|
if current_exists != bool(expected_exists) or current_hash != expected_hash:
|
|
531
612
|
return False
|
|
532
613
|
|
|
533
614
|
if item.get("existed"):
|
|
615
|
+
raw = _snapshot_before_bytes(item, root)
|
|
616
|
+
if raw is None:
|
|
617
|
+
return False
|
|
534
618
|
if missing_parent:
|
|
535
619
|
parent_fd = root_fd
|
|
536
620
|
for part in parts[:-1]:
|
|
@@ -541,7 +625,6 @@ def _restore_file_posix(root, rel, item, expected_exists, expected_hash):
|
|
|
541
625
|
child_fd = os.open(part, directory_flags, dir_fd=parent_fd)
|
|
542
626
|
owned_fds.append(child_fd)
|
|
543
627
|
parent_fd = child_fd
|
|
544
|
-
raw = base64.b64decode(item.get("before_b64") or "", validate=True)
|
|
545
628
|
temp_name = ".codebee-restore-%s.tmp" % secrets.token_hex(12)
|
|
546
629
|
temp_fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
547
630
|
| getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=parent_fd)
|
|
@@ -611,7 +694,10 @@ def restore_files(run_id, workdir):
|
|
|
611
694
|
conflicts.append(rel)
|
|
612
695
|
continue
|
|
613
696
|
if item.get("existed"):
|
|
614
|
-
raw =
|
|
697
|
+
raw = _snapshot_before_bytes(item, root)
|
|
698
|
+
if raw is None:
|
|
699
|
+
conflicts.append(rel)
|
|
700
|
+
continue
|
|
615
701
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
616
702
|
safe_root, safe_rel = _safe_snapshot_path(root, root / rel)
|
|
617
703
|
if safe_root is None or safe_rel != rel:
|
package/app/core/eval_matrix.py
CHANGED
|
@@ -56,12 +56,20 @@ def evaluate(manifest, results, baseline=None):
|
|
|
56
56
|
manifest = normalize_manifest(manifest)
|
|
57
57
|
results = results if isinstance(results, dict) else {}
|
|
58
58
|
baseline = baseline if isinstance(baseline, dict) else {}
|
|
59
|
+
baseline_present = bool(baseline)
|
|
60
|
+
baseline_manifest_hash = (baseline.get("manifest_sha256")
|
|
61
|
+
if isinstance(baseline.get("manifest_sha256"), str) else None)
|
|
62
|
+
baseline_scores = baseline.get("scores") if isinstance(baseline.get("scores"), dict) else baseline
|
|
63
|
+
baseline_compatible = (not baseline_present or
|
|
64
|
+
bool(baseline_manifest_hash) and
|
|
65
|
+
baseline_manifest_hash == manifest["manifest_sha256"])
|
|
66
|
+
comparable_baseline = baseline_scores if baseline_compatible else {}
|
|
59
67
|
matrix, regressions, totals = [], [], {}
|
|
60
68
|
for candidate in manifest["candidates"]:
|
|
61
69
|
scores = []
|
|
62
70
|
for case in manifest["cases"]:
|
|
63
71
|
candidate_results = results.get(candidate)
|
|
64
|
-
candidate_baseline =
|
|
72
|
+
candidate_baseline = comparable_baseline.get(candidate)
|
|
65
73
|
value = (candidate_results.get(case["id"]) if isinstance(candidate_results, dict) else None)
|
|
66
74
|
old_value = (candidate_baseline.get(case["id"]) if isinstance(candidate_baseline, dict) else None)
|
|
67
75
|
score = _score(value)
|
|
@@ -79,4 +87,5 @@ def evaluate(manifest, results, baseline=None):
|
|
|
79
87
|
return {"manifest": manifest, "matrix": matrix, "averages": totals,
|
|
80
88
|
"best_candidate": ranked[0][1] if ranked else "",
|
|
81
89
|
"regressions": regressions,
|
|
90
|
+
"baseline_compatible": baseline_compatible,
|
|
82
91
|
"passed": not regressions}
|
package/app/core/mcp_client.py
CHANGED
|
@@ -98,10 +98,15 @@ def _sandbox_launch(server, sandbox, workdir):
|
|
|
98
98
|
from . import policy
|
|
99
99
|
if not sandbox or not workdir:
|
|
100
100
|
raise RuntimeError("MCP 调用缺少任务沙箱策略或工作目录,拒绝启动")
|
|
101
|
-
if os.name != "posix" or sys.platform == "darwin" or not shutil.which("bwrap"):
|
|
102
|
-
raise RuntimeError("MCP 服务尚无可用的 OS 隔离后端,拒绝启动")
|
|
103
101
|
root = Path(workdir).expanduser().resolve(strict=True)
|
|
104
102
|
normalized = policy.normalize_sandbox(sandbox, root)
|
|
103
|
+
if normalized.get("backend") == "docker":
|
|
104
|
+
# MCP command/args may include host-specific executables and absolute
|
|
105
|
+
# script paths. Until those are explicitly mapped into an image and
|
|
106
|
+
# verified mounts, refuse instead of launching a misleading container.
|
|
107
|
+
raise RuntimeError("MCP Docker 启动映射尚未配置;拒绝降级到宿主机")
|
|
108
|
+
if os.name != "posix" or sys.platform == "darwin" or not shutil.which("bwrap"):
|
|
109
|
+
raise RuntimeError("MCP 服务尚无可用的 OS 隔离后端,拒绝启动")
|
|
105
110
|
roots = [Path(value).resolve(strict=True)
|
|
106
111
|
for value in normalized.get("allowed_roots") or []]
|
|
107
112
|
if not roots:
|
package/app/core/openapi.py
CHANGED
|
@@ -12,6 +12,14 @@ def document():
|
|
|
12
12
|
"TaskCreate": {"type": "object", "required": ["goal"], "properties": {
|
|
13
13
|
"type": {"type": "string"}, "title": {"type": "string"},
|
|
14
14
|
"goal": {"type": "string", "maxLength": 4000}, "workdir": {"type": "string"},
|
|
15
|
+
"sandbox": {"type": "object", "properties": {
|
|
16
|
+
"backend": {"type": "string", "enum": ["native", "docker"]},
|
|
17
|
+
"docker_image": {"type": "string", "maxLength": 240},
|
|
18
|
+
"allowed_roots": {"type": "array", "maxItems": 16,
|
|
19
|
+
"items": {"type": "string", "maxLength": 1024}},
|
|
20
|
+
"env_allowlist": {"type": "array", "maxItems": 64,
|
|
21
|
+
"items": {"type": "string", "maxLength": 128}},
|
|
22
|
+
"network": {"type": "boolean"}, "timeout_s": {"type": "integer"}}},
|
|
15
23
|
"acceptance_criteria": {"type": "array", "maxItems": 40,
|
|
16
24
|
"items": {"type": "string", "maxLength": 500}},
|
|
17
25
|
"approval_required": {"type": "boolean"}}},
|