codebee 0.1.55 → 0.1.57
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 +13 -0
- package/README.md +4 -3
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/beekeeper.py +183 -0
- package/app/core/branching.py +31 -6
- package/app/core/jobs.py +5 -5
- package/app/core/knowledge.py +5 -0
- package/app/core/modelhub.py +17 -1
- package/app/core/pipeline.py +4156 -4117
- package/app/core/publish/browser.py +4 -0
- package/app/core/remote.py +2 -0
- package/app/core/runner.py +444 -357
- package/app/core/selfupdate.py +19 -18
- package/app/core/skill_scan.py +5 -0
- package/app/core/store.py +95 -64
- package/app/core/zentao.py +93 -0
- package/app/main.py +2805 -2728
- package/app/ui/app.js +13954 -13416
- package/app/ui/board.css +732 -390
- package/app/ui/i18n.js +11 -2
- package/app/ui/index.html +1 -2
- package/app/ui/style.css +6106 -6105
- package/package.json +1 -1
package/app/core/selfupdate.py
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
升级 = 在标准 mgmt run 里跑 `npm install -g codebee@latest`(日志实时落盘、
|
|
11
11
|
SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进内存不受影响,装完后由
|
|
12
|
-
|
|
13
|
-
同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
|
|
12
|
+
自动重启换新代码:新进程先等旧端口释放再 bind(Windows SO_REUSEADDR 允许双
|
|
13
|
+
LISTEN 同时存在,必须先验旧进程真退了),旧进程发送完重启响应后自退。
|
|
14
14
|
|
|
15
15
|
安全:两处子进程命令的 argv 均为**行内字面量列表**(可执行文件与全部参数不来自任何
|
|
16
16
|
外部输入;重启仅透传 argparse 校验过的整型端口),shell 全程 False,绝不拼接用户输入。
|
|
@@ -170,7 +170,7 @@ def apply_upgrade(port=None):
|
|
|
170
170
|
port=服务端口:升级成功且版本真变时会自动就地重启(用户拍板 2026-09-21:
|
|
171
171
|
升级完不该再要求手动点「重启服务生效」——旧进程滞留是 unknown api/界面
|
|
172
172
|
闪烁/老宠物一类「升级了没生效」事故的总根子)。拿不到端口或运行环境不
|
|
173
|
-
|
|
173
|
+
具备时才跳过自动重启。"""
|
|
174
174
|
global _PENDING_PORT
|
|
175
175
|
try:
|
|
176
176
|
_PENDING_PORT = int(port) if port else None
|
|
@@ -237,10 +237,10 @@ def _log_note(log_path, text):
|
|
|
237
237
|
|
|
238
238
|
|
|
239
239
|
def _maybe_auto_relaunch(old_pkg, log_path):
|
|
240
|
-
"""
|
|
241
|
-
①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
"""升级成功后的自动重启:
|
|
241
|
+
①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
|
|
242
|
+
③等当前用户任务自然结束后再原子停止接单——正在干活的任务不能被升级
|
|
243
|
+
重启打断,也不再要求用户找到一个可能不存在的手动重启按钮。"""
|
|
244
244
|
import threading
|
|
245
245
|
from . import jobs
|
|
246
246
|
if not _PENDING_PORT:
|
|
@@ -250,26 +250,27 @@ def _maybe_auto_relaunch(old_pkg, log_path):
|
|
|
250
250
|
if not old_pkg or new_pkg == old_pkg:
|
|
251
251
|
_log_note(log_path, "版本未变化(%s),无需重启" % (new_pkg or "?"))
|
|
252
252
|
return
|
|
253
|
-
|
|
254
|
-
_log_note(log_path, "检测到还有 %d 个任务在运行,不自动重启——"
|
|
255
|
-
"完成后请在版本页手动点「重启服务生效」" % (jobs._alive - 1))
|
|
256
|
-
return
|
|
257
|
-
port = _PENDING_PORT
|
|
253
|
+
port = _PENDING_PORT
|
|
258
254
|
|
|
259
255
|
def _go():
|
|
260
256
|
drain_started = False
|
|
257
|
+
wait_noted = False
|
|
261
258
|
try:
|
|
262
259
|
time.sleep(3.0) # 留出日志收尾/浏览器看到「升级完成」的窗口
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
260
|
+
while not drain_started:
|
|
261
|
+
if (not wait_noted and
|
|
262
|
+
(getattr(jobs, "_alive", 0) > 1 or
|
|
263
|
+
getattr(jobs, "_chat_alive", 0) > 0)):
|
|
264
|
+
_log_note(log_path, "等待当前任务完成后自动重启服务…")
|
|
265
|
+
wait_noted = True
|
|
266
|
+
drain_started = jobs.begin_restart_drain()
|
|
267
|
+
if not drain_started:
|
|
268
|
+
jobs.wait_for_idle(timeout=1.0)
|
|
268
269
|
_log_note(log_path, "自动重启服务以应用新版本 %s …" % new_pkg)
|
|
269
270
|
if relaunch(port):
|
|
270
271
|
self_quit()
|
|
271
272
|
except Exception:
|
|
272
|
-
log.exception("selfupdate:
|
|
273
|
+
log.exception("selfupdate: 自动重启失败")
|
|
273
274
|
finally:
|
|
274
275
|
# 正常 self_quit 会直接结束进程;若拉起失败、异常或测试替身返回,必须
|
|
275
276
|
# 释放停止接单闸,避免当前实例永久拒绝新任务。
|
package/app/core/skill_scan.py
CHANGED
|
@@ -19,14 +19,19 @@ _PATTERNS = [
|
|
|
19
19
|
(r"\beval\s*\(", "代码执行", "动态 eval 执行任意代码"),
|
|
20
20
|
(r"\bexec\s*\(", "代码执行", "exec 执行任意代码"),
|
|
21
21
|
(r"subprocess|os\.system|Popen", "代码执行", "起子进程执行命令"),
|
|
22
|
+
(r"child_process", "代码执行", "Node.js 子进程(可能执行任意命令)"),
|
|
22
23
|
# 数据外发
|
|
23
24
|
(r"https?://(?!api\.|docs\.|github\.com|raw\.githubusercontent)[a-z0-9.-]+/(upload|collect|track|ingest|webhook|callback)",
|
|
24
25
|
"数据外发", "向非常规端点上传/回调数据"),
|
|
25
26
|
(r"requests\.post|urllib\.request|fetch\s*\(", "网络请求", "发起网络请求(确认目标可信)"),
|
|
27
|
+
(r"base64.{0,10}(decode|b64decode|atob)", "数据外发",
|
|
28
|
+
"base64 解码(可能隐藏混淆载荷)"),
|
|
26
29
|
# 敏感信息读取
|
|
27
30
|
(r"os\.environ|process\.env|getenv", "环境读取", "读取环境变量(可能带走密钥)"),
|
|
28
31
|
(r"\.ssh/|\.aws/|\.npmrc|\.gitconfig|credentials|\.env\b", "敏感文件", "触碰凭据/密钥文件路径"),
|
|
29
32
|
(r"keychain|credential manager|dpapi", "敏感文件", "访问系统凭据库"),
|
|
33
|
+
(r"\.claude/|\.zcode/|\.kimi-code/|\.codebee/", "敏感文件",
|
|
34
|
+
"触碰 AI 助手配置目录(可能篡改系统提示词或模型绑定)"),
|
|
30
35
|
# 提示注入特征
|
|
31
36
|
(r"(ignore|disregard|forget).{0,30}(previous|above|prior|all).{0,20}(instruction|prompt|rule)",
|
|
32
37
|
"提示注入", "指令覆盖话术(试图无视既有规则)"),
|
package/app/core/store.py
CHANGED
|
@@ -28,11 +28,11 @@ _RUNS = {}
|
|
|
28
28
|
# 长跑也不会无限增长(finish_step 里调 clear_stream_state)。
|
|
29
29
|
_STREAM_LOCK = threading.Lock()
|
|
30
30
|
_STREAM_TS = {}
|
|
31
|
-
LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
|
|
32
|
-
|
|
33
|
-
DEFAULT_TASK_TIMEOUT_S = 3600
|
|
34
|
-
MIN_TASK_TIMEOUT_S = 1
|
|
35
|
-
MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
|
|
31
|
+
LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
|
|
32
|
+
|
|
33
|
+
DEFAULT_TASK_TIMEOUT_S = 3600
|
|
34
|
+
MIN_TASK_TIMEOUT_S = 1
|
|
35
|
+
MAX_TASK_TIMEOUT_S = 7 * 24 * 3600
|
|
36
36
|
|
|
37
37
|
# ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
|
|
38
38
|
# 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
|
|
@@ -61,38 +61,38 @@ def wait_state_change(last_ver, timeout):
|
|
|
61
61
|
return _STATE_VER
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def _new_id(prefix):
|
|
65
|
-
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
66
|
-
# 10k random suffixes are not unique under a burst of task/run creation.
|
|
67
|
-
# Reserve against both indexes and disk so a collision cannot silently
|
|
68
|
-
# overwrite a queued run and make one of several submissions disappear.
|
|
69
|
-
with LOCK:
|
|
70
|
-
for _ in range(100):
|
|
71
|
-
candidate = "%s-%s-%04d" % (prefix, stamp, secrets.randbelow(10000))
|
|
72
|
-
if candidate in _TASKS or candidate in _RUNS:
|
|
73
|
-
continue
|
|
74
|
-
if (paths.TASKS_DIR / (candidate + ".json")).exists():
|
|
75
|
-
continue
|
|
76
|
-
if (paths.RUNS_DIR / candidate).exists():
|
|
77
|
-
continue
|
|
78
|
-
return candidate
|
|
79
|
-
raise RuntimeError("无法生成唯一记录 ID")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _safe_name(s):
|
|
83
|
-
return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _task_timeout_s(value=None):
|
|
87
|
-
"""Resolve one bounded wall-clock budget for a task."""
|
|
88
|
-
raw = value
|
|
89
|
-
if raw in (None, ""):
|
|
90
|
-
raw = os.environ.get("TUTTI_TASK_TIMEOUT_S", DEFAULT_TASK_TIMEOUT_S)
|
|
91
|
-
try:
|
|
92
|
-
seconds = int(float(raw))
|
|
93
|
-
except (TypeError, ValueError):
|
|
94
|
-
seconds = DEFAULT_TASK_TIMEOUT_S
|
|
95
|
-
return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
|
|
64
|
+
def _new_id(prefix):
|
|
65
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
66
|
+
# 10k random suffixes are not unique under a burst of task/run creation.
|
|
67
|
+
# Reserve against both indexes and disk so a collision cannot silently
|
|
68
|
+
# overwrite a queued run and make one of several submissions disappear.
|
|
69
|
+
with LOCK:
|
|
70
|
+
for _ in range(100):
|
|
71
|
+
candidate = "%s-%s-%04d" % (prefix, stamp, secrets.randbelow(10000))
|
|
72
|
+
if candidate in _TASKS or candidate in _RUNS:
|
|
73
|
+
continue
|
|
74
|
+
if (paths.TASKS_DIR / (candidate + ".json")).exists():
|
|
75
|
+
continue
|
|
76
|
+
if (paths.RUNS_DIR / candidate).exists():
|
|
77
|
+
continue
|
|
78
|
+
return candidate
|
|
79
|
+
raise RuntimeError("无法生成唯一记录 ID")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _safe_name(s):
|
|
83
|
+
return re.sub(r"[^0-9A-Za-z_-]+", "-", str(s))[:40].strip("-") or "x"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _task_timeout_s(value=None):
|
|
87
|
+
"""Resolve one bounded wall-clock budget for a task."""
|
|
88
|
+
raw = value
|
|
89
|
+
if raw in (None, ""):
|
|
90
|
+
raw = os.environ.get("TUTTI_TASK_TIMEOUT_S", DEFAULT_TASK_TIMEOUT_S)
|
|
91
|
+
try:
|
|
92
|
+
seconds = int(float(raw))
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
seconds = DEFAULT_TASK_TIMEOUT_S
|
|
95
|
+
return max(MIN_TASK_TIMEOUT_S, min(MAX_TASK_TIMEOUT_S, seconds))
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
# ---------------------------------------------------------------- 任务
|
|
@@ -158,8 +158,8 @@ def create_task(payload):
|
|
|
158
158
|
"status": "created",
|
|
159
159
|
}
|
|
160
160
|
thinking = str(payload.get("thinking") or "standard").strip().lower()
|
|
161
|
-
task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
|
|
162
|
-
task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
|
|
161
|
+
task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
|
|
162
|
+
task["timeout_s"] = _task_timeout_s(payload.get("timeout_s"))
|
|
163
163
|
if flow["engine"] == "direct":
|
|
164
164
|
task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
|
|
165
165
|
"direct_provider_id")[:80]
|
|
@@ -546,7 +546,7 @@ def load_all():
|
|
|
546
546
|
_save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
|
|
547
547
|
continue
|
|
548
548
|
latest = max(runs, key=lambda r: r["id"])
|
|
549
|
-
if latest.get("status") in ("done", "failed", "cancelled", "timeout"):
|
|
549
|
+
if latest.get("status") in ("done", "failed", "cancelled", "timeout"):
|
|
550
550
|
t["status"] = latest["status"]
|
|
551
551
|
_save_json(paths.TASKS_DIR / (t["id"] + ".json"), t)
|
|
552
552
|
|
|
@@ -562,7 +562,7 @@ def set_run_estimator(estimator):
|
|
|
562
562
|
_RUN_ESTIMATOR = estimator if callable(estimator) else None
|
|
563
563
|
|
|
564
564
|
|
|
565
|
-
def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
565
|
+
def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
566
566
|
run = {
|
|
567
567
|
"id": _new_id("r" if kind == "orchestration" else "m"),
|
|
568
568
|
"kind": kind, # orchestration | mgmt
|
|
@@ -570,14 +570,14 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
|
570
570
|
"task_id": task_id, "entry_id": entry_id, "op": op,
|
|
571
571
|
"status": "queued", "steps": [], "messages": [],
|
|
572
572
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
573
|
-
"started_at": None, "ended_at": None,
|
|
574
|
-
"deadline_at": None,
|
|
573
|
+
"started_at": None, "ended_at": None,
|
|
574
|
+
"deadline_at": None,
|
|
575
575
|
"cost_usd": 0.0, "tokens": 0, "error": "",
|
|
576
576
|
"verdict": None, "summary": "",
|
|
577
577
|
}
|
|
578
|
-
if kind == "orchestration" and task_id:
|
|
579
|
-
task = get_task(task_id) or {}
|
|
580
|
-
run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
|
|
578
|
+
if kind == "orchestration" and task_id:
|
|
579
|
+
task = get_task(task_id) or {}
|
|
580
|
+
run["deadline_at"] = time.time() + _task_timeout_s(task.get("timeout_s"))
|
|
581
581
|
try:
|
|
582
582
|
eta = _RUN_ESTIMATOR(
|
|
583
583
|
task_type=task.get("type") or "", mode=task.get("mode") or "auto",
|
|
@@ -661,6 +661,37 @@ def task_runs(task_id):
|
|
|
661
661
|
return runs
|
|
662
662
|
|
|
663
663
|
|
|
664
|
+
def task_runs_page(task_id, offset=0, limit=3):
|
|
665
|
+
"""分页读取任务运行,只复制当前页;全量轻统计在详情打开时按需计算。"""
|
|
666
|
+
with LOCK:
|
|
667
|
+
runs = [r for r in _RUNS.values() if r.get("task_id") == task_id]
|
|
668
|
+
runs.sort(key=lambda run: run["id"], reverse=True)
|
|
669
|
+
page = [dict(run) for run in runs[offset:offset + limit]]
|
|
670
|
+
total_steps = settled_steps = total_tokens = 0
|
|
671
|
+
total_cost = 0.0
|
|
672
|
+
for run in runs:
|
|
673
|
+
steps = run.get("steps") or []
|
|
674
|
+
total_steps += len(steps)
|
|
675
|
+
settled_steps += sum(1 for step in steps
|
|
676
|
+
if step.get("status") not in ("running", "queued"))
|
|
677
|
+
try:
|
|
678
|
+
total_cost += float(run.get("cost_usd") or 0)
|
|
679
|
+
except (TypeError, ValueError):
|
|
680
|
+
pass
|
|
681
|
+
try:
|
|
682
|
+
total_tokens += int(run.get("tokens") or 0)
|
|
683
|
+
except (TypeError, ValueError):
|
|
684
|
+
pass
|
|
685
|
+
return {
|
|
686
|
+
"runs": page,
|
|
687
|
+
"total": len(runs),
|
|
688
|
+
"offset": offset,
|
|
689
|
+
"has_more": offset + len(page) < len(runs),
|
|
690
|
+
"totals": {"steps": total_steps, "settled_steps": settled_steps,
|
|
691
|
+
"cost_usd": total_cost, "tokens": total_tokens},
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
|
|
664
695
|
def task_side(task_id):
|
|
665
696
|
"""任务检查器(右缘停靠列)的轻量聚合:最新 run 摘要 + 进度步骤 +
|
|
666
697
|
git 分支/裁决状态 + 变更行级统计 + 成品文件。
|
|
@@ -762,27 +793,27 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
762
793
|
# 这套在落终态瞬间即时收口——打磨组长被取消/异常打断时 UI 不闪
|
|
763
794
|
# 假「工作中」)。run 终态后不可能还有步骤在真跑。
|
|
764
795
|
st = fields.get("status")
|
|
765
|
-
if st in ("done", "failed", "cancelled", "timeout"):
|
|
766
|
-
end_s = time.strftime("%H:%M:%S")
|
|
767
|
-
for s in run["steps"]:
|
|
768
|
-
if s.get("status") in ("queued", "running"):
|
|
769
|
-
s["status"] = "timeout" if st == "timeout" else "cancelled"
|
|
770
|
-
s["ended_at"] = s.get("ended_at") or end_s
|
|
771
|
-
if not s.get("summary"):
|
|
772
|
-
s["summary"] = ("步骤未正常收尾(任务总时限已到)"
|
|
773
|
-
if st == "timeout" else
|
|
774
|
-
"步骤未正常收尾(终态自动恢复)")
|
|
796
|
+
if st in ("done", "failed", "cancelled", "timeout"):
|
|
797
|
+
end_s = time.strftime("%H:%M:%S")
|
|
798
|
+
for s in run["steps"]:
|
|
799
|
+
if s.get("status") in ("queued", "running"):
|
|
800
|
+
s["status"] = "timeout" if st == "timeout" else "cancelled"
|
|
801
|
+
s["ended_at"] = s.get("ended_at") or end_s
|
|
802
|
+
if not s.get("summary"):
|
|
803
|
+
s["summary"] = ("步骤未正常收尾(任务总时限已到)"
|
|
804
|
+
if st == "timeout" else
|
|
805
|
+
"步骤未正常收尾(终态自动恢复)")
|
|
775
806
|
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
776
807
|
# 状态回填:起跑与结束都同步任务状态。只回填终态的话,run 在跑、
|
|
777
808
|
# 任务永远显示「排队中」(2026-09-18 实案:「# 重写·续」run 已 running
|
|
778
809
|
# 写了一小时,任务卡 queued,用户以为卡死连点重试)。
|
|
779
|
-
if st in ("running", "done", "failed", "cancelled", "timeout"):
|
|
810
|
+
if st in ("running", "done", "failed", "cancelled", "timeout"):
|
|
780
811
|
tid = run.get("task_id")
|
|
781
812
|
task = _TASKS.get(tid) if tid else None
|
|
782
813
|
if task:
|
|
783
814
|
task["status"] = st
|
|
784
815
|
_save_json(paths.TASKS_DIR / (tid + ".json"), task)
|
|
785
|
-
if st in ("done", "failed", "cancelled", "timeout"):
|
|
816
|
+
if st in ("done", "failed", "cancelled", "timeout"):
|
|
786
817
|
# run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
|
|
787
818
|
# (回写知识库等);失败/超时静默,绝不拖慢收尾路径
|
|
788
819
|
try:
|
|
@@ -873,17 +904,17 @@ def recover_orphaned_runs():
|
|
|
873
904
|
step_now = time.strftime("%H:%M:%S")
|
|
874
905
|
with LOCK:
|
|
875
906
|
for r in _RUNS.values():
|
|
876
|
-
if r.get("status") not in ("done", "failed", "cancelled", "timeout"):
|
|
877
|
-
continue
|
|
907
|
+
if r.get("status") not in ("done", "failed", "cancelled", "timeout"):
|
|
908
|
+
continue
|
|
878
909
|
changed = False
|
|
879
910
|
for s in r.get("steps") or []:
|
|
880
911
|
if s.get("status") in ("queued", "running"):
|
|
881
|
-
s["status"] = "timeout" if r.get("status") == "timeout" else "cancelled"
|
|
912
|
+
s["status"] = "timeout" if r.get("status") == "timeout" else "cancelled"
|
|
882
913
|
s["ended_at"] = s.get("ended_at") or step_now
|
|
883
914
|
if not s.get("summary"):
|
|
884
|
-
s["summary"] = ("步骤未正常收尾(任务总时限已到)"
|
|
885
|
-
if r.get("status") == "timeout" else
|
|
886
|
-
"步骤未正常收尾(取消/中断自动恢复)")
|
|
915
|
+
s["summary"] = ("步骤未正常收尾(任务总时限已到)"
|
|
916
|
+
if r.get("status") == "timeout" else
|
|
917
|
+
"步骤未正常收尾(取消/中断自动恢复)")
|
|
887
918
|
changed = True
|
|
888
919
|
if changed:
|
|
889
920
|
_save_json(paths.RUNS_DIR / r["id"] / "run.json", r)
|
package/app/core/zentao.py
CHANGED
|
@@ -329,6 +329,10 @@ _TOKEN = {"v": "", "at": 0.0}
|
|
|
329
329
|
_APIBASE = {}
|
|
330
330
|
# 通道自适应:原始地址 → "rest"(≥15 REST v1)/ "old"(老版 module-method JSON 接口)
|
|
331
331
|
_MODE = {}
|
|
332
|
+
# web 端路由形态:原始地址 → "pathinfo"(伪静态 bug-view-N.html,官方默认)
|
|
333
|
+
# / "get"(index.php?m=bug&f=view&bugID=N)。bug 详情链接用(2026-09-23 真机实证
|
|
334
|
+
# 伪静态部署对 GET 式链接不路由)。
|
|
335
|
+
_WEBSTYLE = {}
|
|
332
336
|
_OLD = {"api": "", "sid": "", "at": 0.0}
|
|
333
337
|
_OLD_FORM = {"form": ""} # 命中的密码形态 "plain" | "md5chain",缓存避免重复试
|
|
334
338
|
_REST_LAST_ERR = {"msg": ""}
|
|
@@ -349,6 +353,37 @@ def resolved_base_url(base_url):
|
|
|
349
353
|
return re.sub(r"/api\.php/v1$", "", hit)
|
|
350
354
|
|
|
351
355
|
|
|
356
|
+
def probe_web_style(base_url):
|
|
357
|
+
"""探测禅道 web 端路由形态(bug 详情链接用):GET web 根(裸根 + 常见 /zentao
|
|
358
|
+
子路径都试),看登录跳转里是伪静态路由(user-login-xxx.html)还是 GET 式
|
|
359
|
+
(index.php?m=user)。尽力而为:任何网络/守卫异常回空串,绝不影响登录与
|
|
360
|
+
扫描主流程;探出即缓存。"""
|
|
361
|
+
try:
|
|
362
|
+
b = _raw_base(base_url)
|
|
363
|
+
except ZenError:
|
|
364
|
+
return ""
|
|
365
|
+
if _WEBSTYLE.get(b):
|
|
366
|
+
return _WEBSTYLE[b]
|
|
367
|
+
style = ""
|
|
368
|
+
for root in (b + "/", b + "/zentao/"):
|
|
369
|
+
body = ""
|
|
370
|
+
try:
|
|
371
|
+
req = urllib.request.Request(_guard_url(root), headers={"Accept": "text/html"})
|
|
372
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
373
|
+
body = (r.read(65536) or b"").decode("utf-8", "replace")
|
|
374
|
+
except Exception:
|
|
375
|
+
body = ""
|
|
376
|
+
if "user-login-" in body or re.search(r"[a-z]+-view-\d+[^\"]*\.html", body):
|
|
377
|
+
style = "pathinfo"
|
|
378
|
+
elif "index.php?m=" in body:
|
|
379
|
+
style = "get"
|
|
380
|
+
if style:
|
|
381
|
+
break
|
|
382
|
+
if style:
|
|
383
|
+
_WEBSTYLE[b] = style
|
|
384
|
+
return style
|
|
385
|
+
|
|
386
|
+
|
|
352
387
|
def _raw_base(base_url):
|
|
353
388
|
b = str(base_url or "").strip().rstrip("/")
|
|
354
389
|
if not b:
|
|
@@ -415,6 +450,7 @@ def _rest_login(base_url, account, password):
|
|
|
415
450
|
_APIBASE[b] = api
|
|
416
451
|
_TOKEN["v"] = tok
|
|
417
452
|
_TOKEN["at"] = time.time()
|
|
453
|
+
probe_web_style(b) # bug 链接形态探测,失败不影响登录
|
|
418
454
|
return tok
|
|
419
455
|
# HTTP 200 但没有 token:REST 在但不认账密(如只认应用 code 的部署)
|
|
420
456
|
_REST_LAST_ERR["msg"] = "REST 接口不认账密(%s)" % (
|
|
@@ -592,6 +628,7 @@ def _old_login(cfg, rest_err=""):
|
|
|
592
628
|
_OLD.update(api=api, sid=sid, at=time.time())
|
|
593
629
|
_OLD_FORM["form"] = form
|
|
594
630
|
_MODE[b] = "old"
|
|
631
|
+
probe_web_style(b) # bug 链接形态探测,失败不影响登录
|
|
595
632
|
return
|
|
596
633
|
last = err
|
|
597
634
|
break # 同一台服务,账密结果与子路径无关,别再烧尝试次数
|
|
@@ -897,6 +934,40 @@ def list_bugs(cfg, product_id, profile=None):
|
|
|
897
934
|
return out[:MAX_BUGS]
|
|
898
935
|
|
|
899
936
|
|
|
937
|
+
def weekly_brief(limit=12):
|
|
938
|
+
"""本周工作素材(工作汇报起草注入用,2026-09-23 Weekly Report Generator 借鉴
|
|
939
|
+
——「从工作系统取数入素材」):分配给我的活跃任务 + 指派给我的活跃 Bug 摘要。
|
|
940
|
+
尽力而为:未配置/不可达/响应形状不对一律返回空串,绝不阻塞汇报起草。
|
|
941
|
+
多产品档案逐个聚合(同 _scan 口径),条目总量封顶 limit*2。"""
|
|
942
|
+
lines = []
|
|
943
|
+
try:
|
|
944
|
+
cfg = _cfg()
|
|
945
|
+
for profile in _profiles(cfg):
|
|
946
|
+
assigned = str(profile.get("assigned_to") or "").strip()
|
|
947
|
+
if not assigned:
|
|
948
|
+
continue
|
|
949
|
+
try:
|
|
950
|
+
d = _call("GET", "/tasks?%s" % urllib.parse.urlencode(
|
|
951
|
+
{"assignedTo": assigned, "limit": limit}), cfg=cfg)
|
|
952
|
+
tasks = d.get("tasks") if isinstance(d, dict) else None
|
|
953
|
+
for t in (tasks or [])[:limit]:
|
|
954
|
+
if isinstance(t, dict) and t.get("status") in ("wait", "doing"):
|
|
955
|
+
lines.append("- [任务#%s] %s(%s)" % (
|
|
956
|
+
t.get("id"), t.get("name") or "", t.get("status") or ""))
|
|
957
|
+
except Exception:
|
|
958
|
+
pass
|
|
959
|
+
try:
|
|
960
|
+
for b in list_bugs(cfg, profile.get("product"), profile)[:limit]:
|
|
961
|
+
lines.append("- [Bug#%s] %s(严重级 %s)" % (
|
|
962
|
+
b.get("id"), b.get("title") or "",
|
|
963
|
+
b.get("severity") or b.get("status") or ""))
|
|
964
|
+
except Exception:
|
|
965
|
+
pass
|
|
966
|
+
except Exception:
|
|
967
|
+
return ""
|
|
968
|
+
return "\n".join(lines[:limit * 2])
|
|
969
|
+
|
|
970
|
+
|
|
900
971
|
def fetch_modules(product_id):
|
|
901
972
|
"""拉产品模块清单(模块路由配置辅助)。接口不存在/失败报人话,提示手填 ID。"""
|
|
902
973
|
_ensure_loaded()
|
|
@@ -1965,6 +2036,24 @@ def save_config(patch):
|
|
|
1965
2036
|
return view()["config"]
|
|
1966
2037
|
|
|
1967
2038
|
|
|
2039
|
+
def bug_web_base(cfg=None):
|
|
2040
|
+
"""给前端拼 bug 详情页用的 web 根:探测出的有效地基(含自动补的 /zentao 子路径)
|
|
2041
|
+
优先,没探测过回落用户填的原始地址。返回 stripped 根或空串。"""
|
|
2042
|
+
c = dict(cfg) if cfg else _cfg()
|
|
2043
|
+
hit = resolved_base_url(c.get("base_url"))
|
|
2044
|
+
if hit:
|
|
2045
|
+
return hit
|
|
2046
|
+
b = re.sub(r"/api\.php/v1$", "", str(c.get("base_url") or "").strip().rstrip("/"))
|
|
2047
|
+
return b if b.startswith(("http://", "https://")) else ""
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
def bug_link_style(cfg=None):
|
|
2051
|
+
"""bug 详情链接形态:探测缓存优先,没探出来按伪静态(官方默认形态)。"""
|
|
2052
|
+
c = dict(cfg) if cfg else _cfg()
|
|
2053
|
+
b = str(c.get("base_url") or "").strip().rstrip("/")
|
|
2054
|
+
return _WEBSTYLE.get(b) or "pathinfo"
|
|
2055
|
+
|
|
2056
|
+
|
|
1968
2057
|
def view():
|
|
1969
2058
|
"""前端视图:配置脱敏(password 只回是否已设)+ claims 列表(新在前)。"""
|
|
1970
2059
|
_ensure_loaded()
|
|
@@ -1973,9 +2062,12 @@ def view():
|
|
|
1973
2062
|
has_pw = bool(cfg.get("password"))
|
|
1974
2063
|
cfg["password"] = ""
|
|
1975
2064
|
cfg["has_password"] = has_pw
|
|
2065
|
+
bug_base = bug_web_base(cfg)
|
|
1976
2066
|
claims = sorted(_STATE["claims"].values(),
|
|
1977
2067
|
key=lambda c: str(c.get("claimed_at") or ""), reverse=True)
|
|
1978
2068
|
return {"config": cfg,
|
|
2069
|
+
"bug_base": bug_base,
|
|
2070
|
+
"bug_style": bug_link_style(cfg),
|
|
1979
2071
|
"claims": [dict(c) for c in claims],
|
|
1980
2072
|
"last_scan": _STATE.get("last_scan") or "",
|
|
1981
2073
|
"next_scan": _STATE.get("next_scan") or "",
|
|
@@ -1990,6 +2082,7 @@ def _test_reset():
|
|
|
1990
2082
|
_STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
|
|
1991
2083
|
_reset_token()
|
|
1992
2084
|
_APIBASE.clear()
|
|
2085
|
+
_WEBSTYLE.clear()
|
|
1993
2086
|
_MODE.clear()
|
|
1994
2087
|
_OLD.update(api="", sid="", at=0.0)
|
|
1995
2088
|
_OLD_FORM["form"] = ""
|