codebee 0.1.66 → 0.1.67

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.
@@ -353,3 +353,37 @@ def merge_titles(plan, named):
353
353
  e["arc"] = arc[:400]
354
354
  out.append(e)
355
355
  return out
356
+
357
+
358
+ def norm_volumes(raw, per, start, n, plan=None):
359
+ """收大纲 JSON 里模型给的卷名/卷弧光,只留本批章号真正覆盖到的卷
360
+ (模型可能多写、乱写卷号,一律按卷规划表过滤;卷边界不采信模型)。
361
+
362
+ raw 形如 {"1": {"title","arc"}} 或 {"1": "卷名"}(键为卷号字符串)。
363
+ 卷边界优先用调用方现成的 plan(显式卷表时与主链推导完全一致),
364
+ 缺省按 per 等长推导。返回 {卷号str: {"title":..., "arc":...}};
365
+ raw 不合规或本批卷号一个都没命中返回 None(调用方用既有卷名兜底)。"""
366
+ if not isinstance(raw, dict):
367
+ return None
368
+ s, cnt = _to_int(start), _to_int(n)
369
+ if s is None or cnt is None or cnt <= 0:
370
+ return None
371
+ if plan is None:
372
+ plan = build_plan(None, per, upto=s + cnt - 1)
373
+ hits = {e["vol"] for e in plan_volumes(plan, s, cnt)}
374
+ if not hits:
375
+ return None
376
+ out = {}
377
+ for key, item in raw.items():
378
+ vol = _to_int(str(key).strip())
379
+ if vol is None or vol not in hits:
380
+ continue
381
+ if isinstance(item, str):
382
+ item = {"title": item}
383
+ if not isinstance(item, dict):
384
+ continue
385
+ title = str(item.get("title") or "").strip()[:MAX_TITLE]
386
+ arc = str(item.get("arc") or "").strip()[:400]
387
+ if title or arc:
388
+ out[str(vol)] = {"title": title, "arc": arc}
389
+ return out or None
@@ -67,7 +67,7 @@ import urllib.request
67
67
  from datetime import datetime, timedelta
68
68
  from pathlib import Path
69
69
 
70
- from . import jobs, paths, settings, store, tlsctx
70
+ from . import jobs, operations, paths, settings, store, tlsctx
71
71
 
72
72
  log = logging.getLogger(__name__)
73
73
 
@@ -1710,7 +1710,16 @@ def _ensure_resolved(cfg, bug_id, comment, assign_to):
1710
1710
  body = {"resolution": "fixed", "resolvedBuild": "trunk", "comment": comment}
1711
1711
  if assign_to:
1712
1712
  body["assignedTo"] = assign_to
1713
- _call("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
1713
+ operation_id = operations.begin("zentao:bug:%s:resolve" % bug_id, body,
1714
+ metadata={"bug_id": bug_id, "action": "resolve"})
1715
+ try:
1716
+ result = _call("POST", "/bugs/%s/resolve" % bug_id, cfg=cfg, body=body)
1717
+ receipt = str((result or {}).get("id") or bug_id)
1718
+ operations.confirm(operation_id, remote_receipt=receipt,
1719
+ metadata={"bug_id": bug_id, "action": "resolve"})
1720
+ except Exception as exc:
1721
+ operations.finish_exception(operation_id, exc)
1722
+ raise
1714
1723
  return True
1715
1724
 
1716
1725
 
@@ -1727,7 +1736,16 @@ def _transfer(cfg, bug_id, target, comment):
1727
1736
  else:
1728
1737
  cur = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
1729
1738
  body["assignedTo"] = _acct(cur.get("assignedTo")) or ""
1730
- _call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
1739
+ operation_id = operations.begin("zentao:bug:%s:transfer" % bug_id, body,
1740
+ metadata={"bug_id": bug_id, "action": "transfer"})
1741
+ try:
1742
+ result = _call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
1743
+ receipt = str((result or {}).get("id") or bug_id)
1744
+ operations.confirm(operation_id, remote_receipt=receipt,
1745
+ metadata={"bug_id": bug_id, "action": "transfer"})
1746
+ except Exception as exc:
1747
+ operations.finish_exception(operation_id, exc)
1748
+ raise
1731
1749
 
1732
1750
 
1733
1751
  def _notify(text):
package/app/main.py CHANGED
@@ -288,6 +288,21 @@ class Handler(BaseHTTPRequestHandler):
288
288
  return self._json(200, {"ok": True, "catalog": manager.catalog_view()})
289
289
  if path == "/api/runs":
290
290
  return self._json(200, {"runs": store.list_runs()})
291
+ if path == "/api/operations":
292
+ from core import operations
293
+ q = parse_qs(urlparse(self.path).query)
294
+ try:
295
+ limit = max(1, min(1000, int((q.get("limit") or [100])[0])))
296
+ except (TypeError, ValueError):
297
+ limit = 100
298
+ return self._json(200, {"operations": operations.recent(
299
+ target=(q.get("target") or [""])[0][:160],
300
+ status=(q.get("status") or [""])[0][:20], limit=limit)})
301
+ m = re.match(r"^/api/operations/([^/]+)$", path)
302
+ if m:
303
+ from core import operations
304
+ item = operations.get(m.group(1))
305
+ return self._json(200, {"operation": item}) if item else self._json(404, {"error": "not found"})
291
306
  if path == "/api/pet_state":
292
307
  return self._api_pet_state()
293
308
  if path == "/api/usage":
@@ -420,6 +435,11 @@ class Handler(BaseHTTPRequestHandler):
420
435
  files = []
421
436
  return self._json(200, {"workdir": wd, "files": files,
422
437
  "task_id": run.get("task_id") or ""})
438
+ m = re.match(r"^/api/runs/([^/]+)/assets$", path)
439
+ if m:
440
+ if not store.get_run(m.group(1)):
441
+ return self._json(404, {"error": "not found"})
442
+ return self._json(200, {"assets": store.run_asset_refs(m.group(1))})
423
443
  m = re.match(r"^/api/runs/([^/]+)/preview$", path)
424
444
  if m:
425
445
  # 网页成品预览:入口 HTML + 同目录代码文件(前端渲染「预览」页签)
@@ -587,7 +607,8 @@ class Handler(BaseHTTPRequestHandler):
587
607
  offset=(q.get("offset") or ["0"])[0],
588
608
  limit=(q.get("limit") or [""])[0],
589
609
  source=(q.get("source") or [""])[0],
590
- q=(q.get("q") or [""])[0]))
610
+ q=(q.get("q") or [""])[0],
611
+ installed=(q.get("installed") or [""])[0]))
591
612
  return self._json(404, {"error": "unknown api"})
592
613
  # 静态文件:单文件或 UI 子目录文件(如 icons/icon-192.png);
593
614
  # _static 内的 parents 校验确保解析后仍在 UI_DIR 内,防穿越
@@ -688,6 +709,20 @@ class Handler(BaseHTTPRequestHandler):
688
709
  return self._api_dir_save()
689
710
  if path == "/api/pick_folder":
690
711
  return self._api_pick_folder()
712
+ m = re.match(r"^/api/operations/([^/]+)/reconcile$", path)
713
+ if m:
714
+ from core import operations
715
+ body = self._body() or {}
716
+ status = str(body.get("status") or "").strip().lower()
717
+ if status not in ("confirmed", "failed", "unknown"):
718
+ return self._json(400, {"error": "status 必须是 confirmed、failed 或 unknown"})
719
+ ok = operations.reconcile(m.group(1), status,
720
+ remote_receipt=body.get("remote_receipt") or "",
721
+ error=body.get("error") or "",
722
+ metadata=body.get("metadata"))
723
+ if not ok:
724
+ return self._json(404, {"error": "操作不存在或状态无效"})
725
+ return self._json(200, {"ok": True, "operation": operations.get(m.group(1))})
691
726
  m = re.match(r"^/api/tasks/([^/]+)/(archive|delete|retry|rename|continue|params)$", path)
692
727
  if m:
693
728
  if m.group(2) == "archive":
@@ -744,8 +779,13 @@ class Handler(BaseHTTPRequestHandler):
744
779
  elif m.group(2) == "rename":
745
780
  ok, err = store.rename_task(m.group(1), self._body().get("title"))
746
781
  elif m.group(2) == "params":
747
- # 对话条三件套(mode/thinking/direct 模型)随发随改:只许空闲态改
782
+ # 对话条三件套(mode/thinking/direct 模型)随发随改:只许空闲态改。
783
+ # 成功回传最新修订号(expected_rev CAS 的续用凭据,前端连推三件
784
+ # 时靠它避免后两次推成过期写入)
748
785
  ok, err = store.update_task_params(m.group(1), self._body())
786
+ if ok:
787
+ _t = store.get_task(m.group(1)) or {}
788
+ return self._json(200, {"ok": True, "rev": _t.get("rev")})
749
789
  else:
750
790
  ok, err = store.delete_task(m.group(1))
751
791
  return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
@@ -987,6 +1027,15 @@ class Handler(BaseHTTPRequestHandler):
987
1027
  from core import skills
988
1028
  n = skills.learn_from_run((self._body().get("run_id") or ""))
989
1029
  return self._json(200, {"ok": True, "learned": n})
1030
+ if path == "/api/skills/evolve":
1031
+ # 教训晋升草稿(ECC /evolve 借鉴):高可信教训聚合成用户包草稿,
1032
+ # 默认停用待人工审阅;返回收录概况
1033
+ from core import skills
1034
+ fname, n = skills.evolve_lessons()
1035
+ if not fname:
1036
+ return self._json(200, {"ok": True, "file": None, "count": 0,
1037
+ "note": "no eligible lessons"})
1038
+ return self._json(200, {"ok": True, "file": fname, "count": n})
990
1039
  if path == "/api/knowledge/op":
991
1040
  from core import knowledge
992
1041
  body = self._body()
@@ -1279,9 +1328,15 @@ class Handler(BaseHTTPRequestHandler):
1279
1328
  # 否则 "remote" 会被当成包名吞掉
1280
1329
  if path == "/api/market/remote/refresh":
1281
1330
  return self._json(200, market_remote.refresh((self._body() or {}).get("source")))
1331
+ if path == "/api/market/remote/preview":
1332
+ # 安装前预览(先看后装):下载+剥离检查但不落盘,带 token 两阶段安装
1333
+ res, err = market_remote.preview_remote((self._body() or {}).get("id") or "")
1334
+ return self._json(400, {"error": err}) if err else self._json(200, res)
1282
1335
  m = re.match(r"^/api/market/remote/install$", path)
1283
1336
  if m:
1284
- res, err = market_remote.install_remote((self._body() or {}).get("id") or "")
1337
+ body = self._body() or {}
1338
+ res, err = market_remote.install_remote(body.get("id") or "",
1339
+ body.get("token") or "")
1285
1340
  return self._json(400, {"error": err}) if err else self._json(200, res)
1286
1341
  m = re.match(r"^/api/market/([^/]+)/(install|remove)$", path)
1287
1342
  if m:
@@ -1333,19 +1388,61 @@ class Handler(BaseHTTPRequestHandler):
1333
1388
  if not task:
1334
1389
  return self._json(404, {"error": "任务不存在"})
1335
1390
  if op == "git-merge":
1336
- ok, err, info = gitmod.merge_task_branch(task.get("workdir"), task)
1337
- if not ok:
1338
- return self._json(400, {"error": err})
1339
- store.set_task_git_state(task_id, "merged")
1340
- return self._json(200, {"ok": True, **(info or {})})
1391
+ from core import operations
1392
+ operation_id = operations.begin(
1393
+ "git:%s:merge" % task_id,
1394
+ {"task_id": task_id, "branch": task.get("git_branch") or ""},
1395
+ task_id=task_id, metadata={"action": "merge"})
1396
+ remote_applied = False
1397
+ try:
1398
+ ok, err, info = gitmod.merge_task_branch(task.get("workdir"), task)
1399
+ if not ok:
1400
+ operations.fail(operation_id, err, metadata={"action": "merge"})
1401
+ return self._json(400, {"error": err})
1402
+ remote_applied = True
1403
+ store.set_task_git_state(task_id, "merged")
1404
+ operations.confirm(operation_id, remote_receipt=(info or {}).get("commit") or "",
1405
+ metadata={"action": "merge"})
1406
+ return self._json(200, {"ok": True, "operation_id": operation_id,
1407
+ **(info or {})})
1408
+ except Exception as exc:
1409
+ # The remote merge may have happened before local state
1410
+ # persistence failed; keep the operation auditable and
1411
+ # prevent a blind retry from duplicating the merge.
1412
+ if remote_applied:
1413
+ operations.mark_unknown(operation_id,
1414
+ "Git 合并可能已完成,但本地状态回写失败:%s" % exc,
1415
+ metadata={"action": "merge"})
1416
+ else:
1417
+ operations.finish_exception(operation_id, exc)
1418
+ return self._json(500, {"error": "Git 合并收口失败:%s" % exc,
1419
+ "operation_id": operation_id})
1341
1420
  body = self._body()
1342
1421
  if not body.get("confirm"):
1343
1422
  return self._json(400, {"error": "丢弃任务分支不可恢复,需要 confirm=true 二次确认"})
1344
- ok, err = gitmod.discard_task_branch(task.get("workdir"), task)
1345
- if not ok:
1346
- return self._json(400, {"error": err})
1347
- store.set_task_git_state(task_id, "discarded")
1348
- return self._json(200, {"ok": True})
1423
+ from core import operations
1424
+ operation_id = operations.begin(
1425
+ "git:%s:discard" % task_id, {"task_id": task_id}, task_id=task_id,
1426
+ metadata={"action": "discard"})
1427
+ remote_applied = False
1428
+ try:
1429
+ ok, err = gitmod.discard_task_branch(task.get("workdir"), task)
1430
+ if not ok:
1431
+ operations.fail(operation_id, err, metadata={"action": "discard"})
1432
+ return self._json(400, {"error": err})
1433
+ remote_applied = True
1434
+ store.set_task_git_state(task_id, "discarded")
1435
+ operations.confirm(operation_id, metadata={"action": "discard"})
1436
+ return self._json(200, {"ok": True, "operation_id": operation_id})
1437
+ except Exception as exc:
1438
+ if remote_applied:
1439
+ operations.mark_unknown(operation_id,
1440
+ "Git 丢弃可能已完成,但本地状态回写失败:%s" % exc,
1441
+ metadata={"action": "discard"})
1442
+ else:
1443
+ operations.finish_exception(operation_id, exc)
1444
+ return self._json(500, {"error": "Git 丢弃收口失败:%s" % exc,
1445
+ "operation_id": operation_id})
1349
1446
 
1350
1447
  def _api_book_meta_generate(self, task_id):
1351
1448
  """作品信息一键生成(POST /api/tasks/<id>/book-meta,body: {platform})。
@@ -1967,21 +2064,36 @@ class Handler(BaseHTTPRequestHandler):
1967
2064
  msgs = r.get("messages") or []
1968
2065
  steps = r.get("steps") or []
1969
2066
  # 消息没有日期(只有 HH:MM:SS),跨 run 不能按时间混排;用语义顺序:
1970
- # 已消费消息(驱动了本轮回答)→ 已完成步骤 → 未消费消息(run 结束后
1971
- # 才追话到达的)→ 运行中/排队步骤(打字动画)。追问因此总在上一轮
1972
- # 回答之后、本轮打字动画之前。
2067
+ # 已消费消息(驱动了本轮回答)→ 已完成步骤 → 运行中/排队步骤 →
2068
+ # 未消费消息。运行中的回复卡锚在开跑位置,中途追话(指挥信箱,还没
2069
+ # 轮到消费)是比卡更新的排队件,必须落在卡下——否则 21:31 开跑的卡
2070
+ # 排在 21:42/21:43 的追问后面,被读成「回复串到了别人的问题」,且步骤
2071
+ # 收尾时消息会整组跳位(2026-09-24 用户实测)。
2072
+ # 排队卡(还没开跑、无 started_at)反向兼容:拿 run 创建时刻当锚点,
2073
+ # 早于锚点的未消费消息(触发本轮排队的那条)仍排在卡前,开跑消费后
2074
+ # 位置不动、时间线不跳变;晚于锚点的才是中途追话,沉底等下一轮。
2075
+ pending = [m for m in msgs if not m.get("consumed")]
1973
2076
  for m in msgs:
1974
2077
  if m.get("consumed"):
1975
2078
  _emit_msg(m)
1976
2079
  for s in steps:
1977
2080
  if (s.get("status") or "") == "done":
1978
2081
  _emit_step(s, r.get("id") or run_id)
1979
- for m in msgs:
1980
- if not m.get("consumed"):
2082
+ live_steps = [s for s in steps if (s.get("status") or "") != "done"]
2083
+ if not live_steps:
2084
+ for m in pending:
2085
+ _emit_msg(m)
2086
+ continue
2087
+ rc = str(r.get("created_at") or "")
2088
+ anchor = (live_steps[0].get("started_at") or "") or (rc[11:19] if len(rc) >= 19 else "")
2089
+ for m in pending:
2090
+ if str(m.get("created_at") or "") <= anchor:
2091
+ _emit_msg(m)
2092
+ for s in live_steps:
2093
+ _emit_step(s, r.get("id") or run_id)
2094
+ for m in pending:
2095
+ if str(m.get("created_at") or "") > anchor:
1981
2096
  _emit_msg(m)
1982
- for s in steps:
1983
- if (s.get("status") or "") != "done":
1984
- _emit_step(s, r.get("id") or run_id)
1985
2097
  return self._json(200, {
1986
2098
  "run_id": run_id, "status": run.get("status") or "",
1987
2099
  "engine": engine,
@@ -2627,6 +2739,13 @@ def main():
2627
2739
  n_mg = store.recover_interrupted_mgmt()
2628
2740
  if n_mg:
2629
2741
  print("[CodeBee] 崩溃恢复:%d 个遗留管理操作标记为 failed(interrupted at startup)" % n_mg)
2742
+ try:
2743
+ from core import operations
2744
+ n_op = operations.recover_pending()
2745
+ if n_op:
2746
+ print("[CodeBee] 外部写入对账:%d 个无回执操作标记为 unknown" % n_op)
2747
+ except Exception:
2748
+ pass
2630
2749
  try:
2631
2750
  # 孤儿 CLI 清扫走后台线程:PowerShell Get-CimInstance 在部分机器上会慢满
2632
2751
  # timeout(真实装机 60s,启动被白拖一分钟且无任何提示——看门狗堆栈抓到)。