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/CHANGELOG.md +8 -0
- package/README.md +431 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +23 -0
- package/app/core/health.py +7 -50
- package/app/core/manager.py +80 -11
- package/app/core/modelhub.py +2883 -2896
- package/app/core/pipeline.py +9 -15
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +26 -3
- package/app/core/selfupdate.py +47 -16
- package/app/core/store.py +138 -47
- package/app/core/usage.py +51 -1
- package/app/main.py +219 -23
- package/app/ui/app.js +519 -156
- package/app/ui/i18n.js +30 -1
- package/app/ui/index.html +57 -45
- package/app/ui/style.css +1056 -2
- package/package.json +1 -1
package/app/core/runner.py
CHANGED
|
@@ -16,6 +16,7 @@ import json
|
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
18
|
import shutil
|
|
19
|
+
import signal
|
|
19
20
|
import subprocess
|
|
20
21
|
import threading
|
|
21
22
|
import time
|
|
@@ -23,7 +24,9 @@ import time
|
|
|
23
24
|
from .env_scrub import scrub_env
|
|
24
25
|
from .error_codes import ErrorCode
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
# 非 Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
|
|
28
|
+
# 置 0 则两边通用(remote.py 同款守卫)。
|
|
29
|
+
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
27
30
|
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
28
31
|
|
|
29
32
|
_BASH_CANDIDATES = [
|
|
@@ -35,6 +38,11 @@ _bash_cache = {"path": None, "done": False}
|
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
def find_git_bash():
|
|
41
|
+
"""Windows 专供:claude 原生 exe 找不到 bash 会拒绝启动,指给 Git Bash。
|
|
42
|
+
macOS/Linux 有系统 bash,claude 自会找到;强行设 CLAUDE_CODE_GIT_BASH_PATH
|
|
43
|
+
反而可能指错(/bin/bash 与 Git Bash 行为有差异),故非 Windows 一律 None。"""
|
|
44
|
+
if os.name != "nt":
|
|
45
|
+
return None
|
|
38
46
|
if _bash_cache["done"]:
|
|
39
47
|
return _bash_cache["path"]
|
|
40
48
|
_bash_cache["done"] = True
|
|
@@ -81,6 +89,19 @@ def _npm_shim_bypass(argv):
|
|
|
81
89
|
|
|
82
90
|
|
|
83
91
|
def _kill_tree(pid):
|
|
92
|
+
"""杀整棵进程树。Windows 用 taskkill /T;POSIX 靠 spawn 时的
|
|
93
|
+
start_new_session(子进程自成一个进程组,pgid==pid)用 killpg 连孙带杀。"""
|
|
94
|
+
if os.name != "nt":
|
|
95
|
+
try:
|
|
96
|
+
os.killpg(pid, signal.SIGKILL)
|
|
97
|
+
return
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
try:
|
|
101
|
+
os.kill(pid, signal.SIGKILL)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return
|
|
84
105
|
try:
|
|
85
106
|
subprocess.run(
|
|
86
107
|
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
@@ -295,7 +316,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
295
316
|
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
|
|
296
317
|
"""
|
|
297
318
|
if shell_cmd:
|
|
298
|
-
|
|
319
|
+
# shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
|
|
320
|
+
argv = ["cmd", "/c", shell_cmd] if os.name == "nt" else ["/bin/sh", "-c", shell_cmd]
|
|
299
321
|
if argv is None:
|
|
300
322
|
return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
|
|
301
323
|
"duration": 0.0, "cancelled": False, "timed_out": False, "stalled": False}
|
|
@@ -327,7 +349,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
327
349
|
[str(a) for a in argv], cwd=cwd, env=full_env,
|
|
328
350
|
stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
|
|
329
351
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
330
|
-
creationflags=CREATE_NO_WINDOW
|
|
352
|
+
creationflags=CREATE_NO_WINDOW,
|
|
353
|
+
start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
|
|
331
354
|
except Exception as e:
|
|
332
355
|
return {"ok": False, "exit_code": None, "stdout": "",
|
|
333
356
|
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
package/app/core/selfupdate.py
CHANGED
|
@@ -18,18 +18,22 @@ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import json
|
|
22
|
-
import
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
23
24
|
import re
|
|
24
25
|
import socket
|
|
25
26
|
import subprocess
|
|
26
27
|
import sys
|
|
27
28
|
import threading
|
|
28
29
|
import time
|
|
30
|
+
from pathlib import Path
|
|
29
31
|
|
|
30
|
-
from . import paths, runner
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
from . import paths, runner
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
|
|
33
37
|
_UPDATE_TTL = 600 # 查新结果缓存(秒)
|
|
34
38
|
_LOCK = threading.Lock()
|
|
35
39
|
_CHECK_CACHE = {"ts": 0.0, "result": None}
|
|
@@ -72,6 +76,13 @@ def _relnotes(readme):
|
|
|
72
76
|
return m.group(1).strip() if m else ""
|
|
73
77
|
|
|
74
78
|
|
|
79
|
+
def _npm_argv(*args):
|
|
80
|
+
"""npm 命令 argv:Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑。"""
|
|
81
|
+
if os.name == "nt":
|
|
82
|
+
return ["cmd", "/c", "npm"] + list(args)
|
|
83
|
+
return ["npm"] + list(args)
|
|
84
|
+
|
|
85
|
+
|
|
75
86
|
def _npm_meta():
|
|
76
87
|
"""npm view <pkg> --json;返回 (latest, relnotes, err)。
|
|
77
88
|
|
|
@@ -79,7 +90,7 @@ def _npm_meta():
|
|
|
79
90
|
GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
|
|
80
91
|
readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
|
|
81
92
|
r = runner.run_process(
|
|
82
|
-
argv=
|
|
93
|
+
argv=_npm_argv("view", _PKG_NAME, "--json"), timeout=60)
|
|
83
94
|
if not r["ok"]:
|
|
84
95
|
return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
|
|
85
96
|
ver, notes = "", ""
|
|
@@ -95,7 +106,7 @@ def _npm_meta():
|
|
|
95
106
|
ver = m.group(0) if m else ""
|
|
96
107
|
if ver and not notes:
|
|
97
108
|
r2 = runner.run_process(
|
|
98
|
-
argv=
|
|
109
|
+
argv=_npm_argv("view", _PKG_NAME, "readme"), timeout=60)
|
|
99
110
|
if r2["ok"]:
|
|
100
111
|
notes = _relnotes(r2["stdout"] or "")
|
|
101
112
|
return ver, notes, ("" if ver else "npm 输出无法解析")
|
|
@@ -150,22 +161,41 @@ def check(force=False):
|
|
|
150
161
|
return out
|
|
151
162
|
|
|
152
163
|
|
|
153
|
-
def apply_upgrade():
|
|
164
|
+
def apply_upgrade():
|
|
154
165
|
"""发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
|
|
155
166
|
if install_mode() != "npm":
|
|
156
167
|
return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
|
|
157
168
|
from . import store, jobs
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
try:
|
|
170
|
+
run = store.create_run(
|
|
171
|
+
"mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
|
|
172
|
+
entry_id="__self__", op="selfupgrade")
|
|
173
|
+
except Exception:
|
|
174
|
+
log.exception("selfupdate: 创建升级运行记录失败")
|
|
175
|
+
return {"error": "升级任务创建失败,请稍后重试"}
|
|
176
|
+
try:
|
|
177
|
+
jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
|
|
178
|
+
except Exception:
|
|
179
|
+
# The run is already durable when enqueue fails. Close it explicitly so
|
|
180
|
+
# the upgrade panel cannot remain in a misleading queued state.
|
|
181
|
+
log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
|
|
182
|
+
try:
|
|
183
|
+
store.update_run(run["id"], status="failed",
|
|
184
|
+
error="升级任务入队失败,请稍后重试",
|
|
185
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
186
|
+
except Exception:
|
|
187
|
+
log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
|
|
188
|
+
return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
|
|
189
|
+
return {"run_id": run["id"]}
|
|
162
190
|
|
|
163
191
|
|
|
164
192
|
def run_upgrade(run_id, log_path):
|
|
165
193
|
"""worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。"""
|
|
166
194
|
res = runner.run_process(
|
|
167
|
-
argv=
|
|
168
|
-
|
|
195
|
+
argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
|
|
196
|
+
# Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
|
|
197
|
+
# cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
|
|
198
|
+
cwd=str(Path.home()), timeout=900, log_path=log_path)
|
|
169
199
|
if res["ok"]:
|
|
170
200
|
with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
|
|
171
201
|
_CHECK_CACHE["result"] = None
|
|
@@ -200,9 +230,10 @@ def relaunch(port):
|
|
|
200
230
|
if port < 1 or port > 65535:
|
|
201
231
|
return False
|
|
202
232
|
subprocess.Popen(
|
|
203
|
-
[sys.executable, "main.py", "--port", str(port),
|
|
233
|
+
[sys.executable, str(paths.APP_DIR / "main.py"), "--port", str(port),
|
|
204
234
|
"--wait-port", "--no-browser"],
|
|
205
|
-
|
|
235
|
+
# 新实例 CWD 同样不得落在包内,否则下次升级 npm 改名包目录再撞 EBUSY
|
|
236
|
+
cwd=str(Path.home()),
|
|
206
237
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
207
238
|
close_fds=True,
|
|
208
239
|
creationflags=(0x00000008 | 0x00000200) if os.name == "nt" else 0)
|
package/app/core/store.py
CHANGED
|
@@ -61,20 +61,30 @@ def _safe_name(s):
|
|
|
61
61
|
|
|
62
62
|
# ---------------------------------------------------------------- 任务
|
|
63
63
|
|
|
64
|
-
def create_task(payload):
|
|
64
|
+
def create_task(payload):
|
|
65
65
|
"""校验并创建任务。payload 至少含 type/goal/workdir。
|
|
66
66
|
|
|
67
67
|
type 必须是 flows.py 里的有效流程 ID;流程参数(引擎/维度/阈值/轮数/产出
|
|
68
68
|
文件/提示词覆盖)在创建时固化到任务上,之后修改流程定义不影响已建任务。
|
|
69
69
|
"""
|
|
70
|
-
|
|
70
|
+
if not isinstance(payload, dict):
|
|
71
|
+
raise ValueError("任务参数必须是 JSON 对象")
|
|
72
|
+
|
|
73
|
+
def _text(value, field):
|
|
74
|
+
if value is None:
|
|
75
|
+
return ""
|
|
76
|
+
if not isinstance(value, str):
|
|
77
|
+
raise ValueError("%s 必须是文本" % field)
|
|
78
|
+
return value.strip()
|
|
79
|
+
|
|
80
|
+
from . import flows as flows_mod
|
|
71
81
|
flow = flows_mod.get_flow(payload.get("type"))
|
|
72
82
|
if flow is None:
|
|
73
83
|
raise ValueError("未知任务类型:%s(可选:%s)"
|
|
74
84
|
% (payload.get("type"), "、".join(f["id"] for f in flows_mod.list_flows())))
|
|
75
|
-
title = (payload.get("title")
|
|
76
|
-
goal = (payload.get("goal")
|
|
77
|
-
workdir = (payload.get("workdir")
|
|
85
|
+
title = _text(payload.get("title"), "title")
|
|
86
|
+
goal = _text(payload.get("goal"), "goal")
|
|
87
|
+
workdir = _text(payload.get("workdir"), "workdir")
|
|
78
88
|
if not goal:
|
|
79
89
|
raise ValueError("目标描述不能为空")
|
|
80
90
|
title = title or goal.splitlines()[0][:30] # 标题可省略,自动取目标首行
|
|
@@ -102,7 +112,7 @@ def create_task(payload):
|
|
|
102
112
|
task = {
|
|
103
113
|
"id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
|
|
104
114
|
"title": title, "goal": goal,
|
|
105
|
-
"context": (payload.get("context")
|
|
115
|
+
"context": _text(payload.get("context"), "context"),
|
|
106
116
|
"workdir": str(wd),
|
|
107
117
|
"mode": mode,
|
|
108
118
|
"difficulty": difficulty,
|
|
@@ -113,7 +123,7 @@ def create_task(payload):
|
|
|
113
123
|
}
|
|
114
124
|
# 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
|
|
115
125
|
from . import gitmod
|
|
116
|
-
git_rev = (payload.get("git_rev")
|
|
126
|
+
git_rev = _text(payload.get("git_rev"), "git_rev")
|
|
117
127
|
if git_rev:
|
|
118
128
|
# 前端下拉值带 kind 前缀(branch:main / tag:v1 / commit:abc),此处归一为纯 rev;
|
|
119
129
|
# git 分支/标签名本身允许含冒号(罕见),前缀剥离只认这三种已知 kind
|
|
@@ -122,11 +132,12 @@ def create_task(payload):
|
|
|
122
132
|
raise ValueError("非法的代码版本引用:%s" % git_rev[:40])
|
|
123
133
|
task["git_rev"] = git_rev
|
|
124
134
|
if flow["engine"] == "code":
|
|
125
|
-
task["verify_command"] = (payload.get("verify_command")
|
|
135
|
+
task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
|
|
126
136
|
elif flow["engine"] == "direct":
|
|
127
137
|
pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
|
|
128
138
|
else:
|
|
129
|
-
ms = (payload.get("manuscript") or flow.get("manuscript") or "manuscript.md"
|
|
139
|
+
ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
|
|
140
|
+
"manuscript")
|
|
130
141
|
ms = re.sub(r"[\\/]", "_", ms) # 只允许工作目录内的相对文件名
|
|
131
142
|
ms = re.sub(r"\.{2,}", "_", ms).lstrip(".") # 顺带清掉残留的 ..
|
|
132
143
|
task["manuscript"] = ms
|
|
@@ -147,8 +158,18 @@ def create_task(payload):
|
|
|
147
158
|
for key in ("draft_prompt", "critique_prompt"): # 自定义流程的提示词覆盖
|
|
148
159
|
if flow.get(key):
|
|
149
160
|
task[key] = flow[key]
|
|
150
|
-
# 连载模式:逐章起草/评审/修订(任务级 serial
|
|
151
|
-
|
|
161
|
+
# 连载模式:逐章起草/评审/修订(任务级 serial 覆盖流程默认)。
|
|
162
|
+
# payload 中显式传 null 表示关闭流程默认连载;字段缺失才沿用流程默认,
|
|
163
|
+
# 这样前端把章节清空时不会被 serial_novel 的默认值悄悄重新打开。
|
|
164
|
+
serial_unset = object()
|
|
165
|
+
serial_value = payload.get("serial", serial_unset)
|
|
166
|
+
if serial_value is None or (isinstance(serial_value, dict) and
|
|
167
|
+
serial_value.get("enabled") is False):
|
|
168
|
+
serial = None
|
|
169
|
+
elif isinstance(serial_value, dict):
|
|
170
|
+
serial = serial_value
|
|
171
|
+
else:
|
|
172
|
+
serial = flow.get("serial")
|
|
152
173
|
if isinstance(serial, dict) and serial.get("chapters"):
|
|
153
174
|
try:
|
|
154
175
|
s = {
|
|
@@ -180,8 +201,17 @@ def create_task(payload):
|
|
|
180
201
|
s["variants"] = v
|
|
181
202
|
task["serial"] = s
|
|
182
203
|
critics = payload.get("critics")
|
|
183
|
-
if isinstance(critics, list) and critics:
|
|
184
|
-
task["critics"] = [str(c) for c in critics]
|
|
204
|
+
if isinstance(critics, list) and critics:
|
|
205
|
+
task["critics"] = [str(c) for c in critics]
|
|
206
|
+
# 初始故事圣经必须在任务入队前落盘,保证首个章节步骤就能读到设定。
|
|
207
|
+
# 只对带连载引擎的任务接收;已有不同内容的圣经拒绝覆盖,避免新任务误伤旧书设定。
|
|
208
|
+
initial_bible = str(payload.get("story_bible") or "").strip()
|
|
209
|
+
if initial_bible:
|
|
210
|
+
if not task.get("serial"):
|
|
211
|
+
raise ValueError("初始故事圣经仅适用于连载小说任务")
|
|
212
|
+
if len(initial_bible) > BIBLE_MAX_CHARS:
|
|
213
|
+
raise ValueError("故事圣经超长(最大 %d 字符,当前 %d 字符)" %
|
|
214
|
+
(BIBLE_MAX_CHARS, len(initial_bible)))
|
|
185
215
|
resume = payload.get("resume")
|
|
186
216
|
if isinstance(resume, dict) and resume.get("agent") and resume.get("session"):
|
|
187
217
|
task["resume"] = {"agent": str(resume["agent"])[:40],
|
|
@@ -191,11 +221,16 @@ def create_task(payload):
|
|
|
191
221
|
proj = str(resume.get("project") or "")[:260]
|
|
192
222
|
if proj:
|
|
193
223
|
task["resume"]["project"] = proj
|
|
194
|
-
#
|
|
224
|
+
# 初始圣经先于附件提交:如果目录已有设定,尽早拒绝,避免附件已移动却
|
|
225
|
+
# 因故事圣经冲突导致任务创建失败。相同内容的重试是幂等的(例如附件
|
|
226
|
+
# 提交中断后重试),不会覆盖已有设定。
|
|
227
|
+
if initial_bible:
|
|
228
|
+
_write_initial_story_bible(str(wd), initial_bible)
|
|
229
|
+
# 附件:把待提交文件移入工作目录 _attachments/,清单注入 context(__CONTEXT__ 全链路可见)。
|
|
195
230
|
# 两种形态:字符串 id = 待提交区文件(新建任务);dict 清单 = 已落盘的附件
|
|
196
231
|
# (继续连载/重试沿用同目录同文件,直接复制清单,不再移文件)。
|
|
197
232
|
att_ids = payload.get("attachments")
|
|
198
|
-
if isinstance(att_ids, list) and att_ids:
|
|
233
|
+
if isinstance(att_ids, list) and att_ids:
|
|
199
234
|
from . import attachments as att_mod
|
|
200
235
|
items = [a for a in att_ids if isinstance(a, dict) and a.get("path")]
|
|
201
236
|
if not items: # 纯 id 形态 → 从待提交区移入工作目录
|
|
@@ -208,9 +243,9 @@ def create_task(payload):
|
|
|
208
243
|
task["attachments"] = items
|
|
209
244
|
blk = att_mod.context_block(items)
|
|
210
245
|
# 复制清单场景下 context 已含附件块(随旧任务沿用),别重复追加
|
|
211
|
-
if blk and "## 附件材料" not in task["context"]:
|
|
212
|
-
task["context"] = (task["context"] + blk).strip()
|
|
213
|
-
with LOCK:
|
|
246
|
+
if blk and "## 附件材料" not in task["context"]:
|
|
247
|
+
task["context"] = (task["context"] + blk).strip()
|
|
248
|
+
with LOCK:
|
|
214
249
|
_TASKS[task["id"]] = task
|
|
215
250
|
_save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
|
|
216
251
|
return task
|
|
@@ -384,13 +419,20 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
|
384
419
|
"started_at": None, "ended_at": None,
|
|
385
420
|
"cost_usd": 0.0, "tokens": 0, "error": "",
|
|
386
421
|
"verdict": None, "summary": "",
|
|
387
|
-
}
|
|
388
|
-
rdir = paths.RUNS_DIR / run["id"]
|
|
389
|
-
(rdir / "steps").mkdir(parents=True, exist_ok=True)
|
|
390
|
-
with LOCK:
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
422
|
+
}
|
|
423
|
+
rdir = paths.RUNS_DIR / run["id"]
|
|
424
|
+
(rdir / "steps").mkdir(parents=True, exist_ok=True)
|
|
425
|
+
with LOCK:
|
|
426
|
+
# 先完成原子落盘,再发布到内存索引。旧顺序在 _save_json 失败时会
|
|
427
|
+
# 留下只存在于 _RUNS 的“幽灵 run”,后续 UI 看到排队记录却永远无法
|
|
428
|
+
# 读取/恢复其 run.json。
|
|
429
|
+
try:
|
|
430
|
+
_save_json(rdir / "run.json", run)
|
|
431
|
+
except Exception:
|
|
432
|
+
_RUNS.pop(run["id"], None)
|
|
433
|
+
raise
|
|
434
|
+
_RUNS[run["id"]] = run
|
|
435
|
+
return run
|
|
394
436
|
|
|
395
437
|
|
|
396
438
|
def get_run(run_id):
|
|
@@ -716,11 +758,11 @@ def task_step_count(task_id):
|
|
|
716
758
|
|
|
717
759
|
# ---------------------------------------------------------------- 故事圣经(story-bible.md)
|
|
718
760
|
|
|
719
|
-
BIBLE_FILE = "story-bible.md"
|
|
720
|
-
BIBLE_MAX_CHARS = 20000
|
|
761
|
+
BIBLE_FILE = "story-bible.md"
|
|
762
|
+
BIBLE_MAX_CHARS = 20000
|
|
721
763
|
|
|
722
764
|
|
|
723
|
-
def _bible_path(workdir):
|
|
765
|
+
def _bible_path(workdir):
|
|
724
766
|
"""工作目录内圣经文件绝对路径;目录穿越直接返回 None(不读外面任何东西)。"""
|
|
725
767
|
wd = str(workdir or "").strip()
|
|
726
768
|
if not wd or not os.path.isdir(wd):
|
|
@@ -729,7 +771,53 @@ def _bible_path(workdir):
|
|
|
729
771
|
p = (root / BIBLE_FILE).resolve()
|
|
730
772
|
if root != p and root not in p.parents:
|
|
731
773
|
return None
|
|
732
|
-
return p
|
|
774
|
+
return p
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _write_initial_story_bible(workdir, text):
|
|
778
|
+
"""创建任务时安全播种故事圣经。
|
|
779
|
+
|
|
780
|
+
初始圣经写入发生在任务入队之前,多个请求可能同时指向同一个工作目录。
|
|
781
|
+
旧逻辑先在锁外检查、再在锁外写入,两个请求都能通过检查,后写请求会
|
|
782
|
+
覆盖先写的设定。这里把检查和写入放进同一进程锁,并在文件不存在时用
|
|
783
|
+
``O_EXCL`` 做最后一道独占创建;已有不同内容的文件始终拒绝覆盖。
|
|
784
|
+
"""
|
|
785
|
+
p = _bible_path(workdir)
|
|
786
|
+
if p is None:
|
|
787
|
+
raise ValueError("故事圣经写入失败:工作目录不可用")
|
|
788
|
+
with LOCK:
|
|
789
|
+
try:
|
|
790
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
791
|
+
if p.exists():
|
|
792
|
+
if not p.is_file():
|
|
793
|
+
raise ValueError("故事圣经写入失败:目标路径不是文件")
|
|
794
|
+
existing = runner.read_text_any_enc(p).strip()
|
|
795
|
+
if existing:
|
|
796
|
+
if existing == text:
|
|
797
|
+
return
|
|
798
|
+
raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
|
|
799
|
+
# 空文件是合法的旧占位文件;在锁内覆盖,避免本服务内的并发写入。
|
|
800
|
+
p.write_text(text, encoding="utf-8")
|
|
801
|
+
return
|
|
802
|
+
try:
|
|
803
|
+
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
804
|
+
except FileExistsError:
|
|
805
|
+
# 其他进程可能刚创建了文件。相同内容可幂等返回;空文件仍可
|
|
806
|
+
# 完成播种,非空不同内容绝不静默覆盖。
|
|
807
|
+
if p.is_file():
|
|
808
|
+
existing = runner.read_text_any_enc(p).strip()
|
|
809
|
+
if existing == text:
|
|
810
|
+
return
|
|
811
|
+
if not existing:
|
|
812
|
+
p.write_text(text, encoding="utf-8")
|
|
813
|
+
return
|
|
814
|
+
raise ValueError("工作目录已有 story-bible.md,请清空初始圣经输入或先编辑已有设定")
|
|
815
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
816
|
+
fh.write(text)
|
|
817
|
+
except ValueError:
|
|
818
|
+
raise
|
|
819
|
+
except OSError as e:
|
|
820
|
+
raise ValueError("故事圣经写入失败:%s" % e)
|
|
733
821
|
|
|
734
822
|
|
|
735
823
|
def read_story_bible(task_id):
|
|
@@ -750,30 +838,33 @@ def read_story_bible(task_id):
|
|
|
750
838
|
return None, None, "读取失败: %s" % e
|
|
751
839
|
|
|
752
840
|
|
|
753
|
-
def write_story_bible(task_id, text):
|
|
841
|
+
def write_story_bible(task_id, text):
|
|
754
842
|
"""写入故事圣经到任务工作目录。守卫:
|
|
755
843
|
- 任务不存在/工作目录越界 → 拒绝;
|
|
756
844
|
- 任务正在运行(queued/running)→ 拒绝(圣经是中流砥柱,运行中不能换骨架);
|
|
757
845
|
- 文本超长(BIBLE_MAX_CHARS)→ 拒绝;
|
|
758
846
|
- 写入失败 → 报错。
|
|
759
847
|
返回 (ok, 错误信息)。"""
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
848
|
+
with LOCK:
|
|
849
|
+
task = get_task(task_id)
|
|
850
|
+
if not task:
|
|
851
|
+
return False, "任务不存在"
|
|
852
|
+
if task.get("status") in ("queued", "running"):
|
|
853
|
+
return False, "任务正在运行,不能修改故事圣经(请等运行结束后再编辑)"
|
|
854
|
+
p = _bible_path(task.get("workdir"))
|
|
855
|
+
if p is None:
|
|
856
|
+
return False, "工作目录不存在或路径越界"
|
|
857
|
+
text = (text or "").strip()
|
|
858
|
+
if len(text) > BIBLE_MAX_CHARS:
|
|
859
|
+
return False, "故事圣经超长(最大 %d 字符,当前 %d 字符)" % (BIBLE_MAX_CHARS, len(text))
|
|
860
|
+
try:
|
|
861
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
862
|
+
p.write_text(text, encoding="utf-8")
|
|
863
|
+
except OSError as e:
|
|
864
|
+
return False, "写入失败: %s" % e
|
|
865
|
+
# 故事圣经是提示词输入,写入后让 SSE/轮询端尽快看到新状态。
|
|
866
|
+
bump_state()
|
|
867
|
+
return True, ""
|
|
777
868
|
|
|
778
869
|
|
|
779
870
|
def read_run_file(run_id, rel):
|
package/app/core/usage.py
CHANGED
|
@@ -214,7 +214,10 @@ def agent_tokens_recent(agent, hours=1):
|
|
|
214
214
|
if str(r.get("ts") or "") < bound:
|
|
215
215
|
continue
|
|
216
216
|
a = str(r.get("agent") or "")
|
|
217
|
-
|
|
217
|
+
# record() 将规范化后的 token 总数落在顶层 ``total``;旧实现
|
|
218
|
+
# 误读不存在的嵌套 usage 字段,导致配额路由永远认为本小时
|
|
219
|
+
# 用量为 0,超过供应商额度也不会降权。
|
|
220
|
+
total[a] = total.get(a, 0) + max(0, _parse_int(r.get("total")))
|
|
218
221
|
_HOURLY_CACHE.update(ts=now, val=total)
|
|
219
222
|
return int(_HOURLY_CACHE["val"].get(agent) or 0)
|
|
220
223
|
except Exception:
|
|
@@ -448,3 +451,50 @@ def summary(days=30, recent_limit=30):
|
|
|
448
451
|
"by_task_type": _dim_rows(records, "task_type"),
|
|
449
452
|
"recent": recent,
|
|
450
453
|
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def estimate(task_type="", days=90):
|
|
457
|
+
"""同类任务开跑前成本预估(借鉴 omnigent 的 pre-run estimate;数据源就是本台账)。
|
|
458
|
+
|
|
459
|
+
同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
|
|
460
|
+
task_type 为空=全类型。无样本时 samples=0,前端不展示。"""
|
|
461
|
+
span = max(1, min(3650, int(days or 90)))
|
|
462
|
+
with LOCK:
|
|
463
|
+
records = _iter_records(span)
|
|
464
|
+
runs = {}
|
|
465
|
+
for r in records:
|
|
466
|
+
tt = str(r.get("task_type") or "unknown")
|
|
467
|
+
if task_type and tt != task_type:
|
|
468
|
+
continue
|
|
469
|
+
rid = str(r.get("run_id") or "")
|
|
470
|
+
if not rid:
|
|
471
|
+
continue
|
|
472
|
+
g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0, "ok": False})
|
|
473
|
+
g["tokens"] += _num(r, "total")
|
|
474
|
+
g["cost"] += _parse_float(r.get("cost_usd"))
|
|
475
|
+
if r.get("ok"):
|
|
476
|
+
g["ok"] = True
|
|
477
|
+
ok_runs = [g for g in runs.values() if g["ok"]]
|
|
478
|
+
basis = ok_runs or list(runs.values())
|
|
479
|
+
if not basis:
|
|
480
|
+
return {"task_type": task_type or "", "days": span, "samples": 0}
|
|
481
|
+
toks = sorted(g["tokens"] for g in basis)
|
|
482
|
+
costs = sorted(g["cost"] for g in basis)
|
|
483
|
+
n = len(toks)
|
|
484
|
+
mid = n // 2
|
|
485
|
+
med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
|
|
486
|
+
med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
|
|
487
|
+
import datetime
|
|
488
|
+
import math
|
|
489
|
+
return {
|
|
490
|
+
"task_type": task_type or "",
|
|
491
|
+
"days": span,
|
|
492
|
+
"samples": n,
|
|
493
|
+
"ok_samples": len(ok_runs),
|
|
494
|
+
"avg_tokens": int(sum(toks) / n),
|
|
495
|
+
"median_tokens": int(med),
|
|
496
|
+
"p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
|
|
497
|
+
"avg_cost_usd": round(sum(costs) / n, 4),
|
|
498
|
+
"median_cost_usd": round(med_c, 4),
|
|
499
|
+
"since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
|
|
500
|
+
}
|