codebee 0.1.68 → 0.1.70

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +580 -543
  2. package/LICENSE +21 -21
  3. package/README.md +11 -0
  4. package/app/browser-final-1440.png +0 -0
  5. package/app/browser-final-390.png +0 -0
  6. package/app/browser-google-fallback.png +0 -0
  7. package/app/browser-google-test.png +0 -0
  8. package/app/browser-workbench-1440.png +0 -0
  9. package/app/browser-workbench-390.png +0 -0
  10. package/app/browser-workspace-google.png +0 -0
  11. package/app/browser-workspace-smoke.png +0 -0
  12. package/app/browser-workspace.png +0 -0
  13. package/app/core/acceptance.py +100 -100
  14. package/app/core/aiflavor.py +197 -197
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -0
  19. package/app/core/automation.py +853 -758
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +95 -0
  23. package/app/core/board.py +209 -209
  24. package/app/core/bookmeta.py +935 -930
  25. package/app/core/bookmeta_catalog.py +180 -180
  26. package/app/core/branching.py +137 -137
  27. package/app/core/builtin_agent.py +1673 -1234
  28. package/app/core/capability.py +144 -144
  29. package/app/core/catalog.py +7 -1
  30. package/app/core/chaptersafe.py +32 -32
  31. package/app/core/cleanup.py +303 -303
  32. package/app/core/compaction.py +201 -201
  33. package/app/core/covergen.py +356 -356
  34. package/app/core/defectretro.py +43 -43
  35. package/app/core/diagnostics.py +114 -114
  36. package/app/core/dispatch.py +190 -173
  37. package/app/core/dispatch_log.py +128 -128
  38. package/app/core/env_scrub.py +85 -85
  39. package/app/core/error_codes.py +33 -24
  40. package/app/core/errorlog.py +137 -137
  41. package/app/core/evalbench.py +720 -0
  42. package/app/core/evaluation.py +174 -174
  43. package/app/core/failure_notify.py +290 -290
  44. package/app/core/flows.py +228 -3
  45. package/app/core/goal_service.py +167 -167
  46. package/app/core/health.py +374 -374
  47. package/app/core/history.py +38 -38
  48. package/app/core/hooks.py +162 -162
  49. package/app/core/jobs.py +51 -30
  50. package/app/core/manager.py +2143 -2122
  51. package/app/core/market.py +362 -362
  52. package/app/core/market_remote.py +10 -0
  53. package/app/core/mcp_client.py +287 -0
  54. package/app/core/mocks.py +64 -64
  55. package/app/core/modelhub.py +193 -10
  56. package/app/core/notify.py +201 -106
  57. package/app/core/novel_quality.py +189 -0
  58. package/app/core/operations.py +228 -228
  59. package/app/core/paihang.py +138 -138
  60. package/app/core/paths.py +6 -2
  61. package/app/core/pipeline.py +426 -28
  62. package/app/core/planner.py +243 -22
  63. package/app/core/plugins.py +525 -0
  64. package/app/core/portguard.py +111 -111
  65. package/app/core/preview.py +293 -293
  66. package/app/core/publish/__init__.py +7 -7
  67. package/app/core/publish/auto.py +367 -367
  68. package/app/core/publish/browser.py +655 -539
  69. package/app/core/publish/fanqie.py +165 -159
  70. package/app/core/publish/flow.py +589 -440
  71. package/app/core/publish/flows-fanqie-calibrated.json +336 -332
  72. package/app/core/publish/ledger.py +229 -203
  73. package/app/core/publish/manager.py +824 -692
  74. package/app/core/publish/qimao.py +146 -141
  75. package/app/core/publish/ws.py +139 -139
  76. package/app/core/redact.py +38 -38
  77. package/app/core/remotesync.py +121 -0
  78. package/app/core/repeat_guard.py +129 -129
  79. package/app/core/router.py +398 -398
  80. package/app/core/runner.py +2142 -2067
  81. package/app/core/selfupdate.py +384 -384
  82. package/app/core/session_log.py +161 -161
  83. package/app/core/sessions.py +312 -312
  84. package/app/core/settings.py +186 -149
  85. package/app/core/settings_schema.py +270 -262
  86. package/app/core/share_page.py +114 -114
  87. package/app/core/skill_scan.py +122 -122
  88. package/app/core/skillpacks/fanqie-novel.md +80 -80
  89. package/app/core/skillpacks/market/character-bible.md +66 -66
  90. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  91. package/app/core/skillpacks/market/git-workflow.md +57 -57
  92. package/app/core/skillpacks/market/release-notes.md +72 -72
  93. package/app/core/skillpacks/market/weekly-report.md +71 -71
  94. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  95. package/app/core/step_runner.py +132 -132
  96. package/app/core/store.py +104 -82
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tracing.py +144 -144
  102. package/app/core/upstream.py +41 -41
  103. package/app/core/usage.py +1035 -862
  104. package/app/core/volumes.py +389 -389
  105. package/app/core/wxdigest.py +858 -858
  106. package/app/core/zentao.py +2614 -2465
  107. package/app/current-browser.png +0 -0
  108. package/app/main.py +3245 -2981
  109. package/app/mcp_server.py +175 -0
  110. package/app/pet.py +82 -21
  111. package/app/pick_dialog.py +110 -110
  112. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  113. package/app/plugins/code-review-helper/skills/review.md +16 -0
  114. package/app/ui/app.js +15706 -14399
  115. package/app/ui/artdiff.js +74 -74
  116. package/app/ui/board.html +162 -162
  117. package/app/ui/board.js +305 -305
  118. package/app/ui/i18n.js +3012 -2770
  119. package/app/ui/icons/bee.svg +79 -79
  120. package/app/ui/index.html +277 -13
  121. package/app/ui/manifest.json +16 -16
  122. package/app/ui/qrcode.js +2297 -2297
  123. package/app/ui/style.css +802 -16
  124. package/bin/tutti.js +147 -147
  125. package/package.json +47 -46
  126. package/tools/wx_reader.py +261 -261
  127. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  128. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  129. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  131. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  132. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  133. package/app/.playwright-cli/run-detail-after.png +0 -0
package/app/core/flows.py CHANGED
@@ -9,10 +9,12 @@
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import base64
12
13
  import hashlib
13
14
  import json
14
15
  import re
15
16
  import threading
17
+ import time
16
18
 
17
19
  from . import paths, volumes
18
20
 
@@ -50,7 +52,7 @@ BUILTIN_FLOWS = [
50
52
  "threshold": 7.0, "rounds": 2,
51
53
  "serial": {"chapters": 8, "words_per_chapter": 2500},
52
54
  "goal_hint": "题材/受众/卖点 + 总字数(例:2 万字都市女频,可签约平台)",
53
- "note": "大纲 → 逐章起草 → 每章多维评审修订 → 全局一致性评审 → 合并(可断点续跑)"},
55
+ "note": "大纲 → 逐章起草 → 签约质量门禁(开篇/推进/文风/情感/节奏)→ 全局一致性评审 → 合并(可断点续跑)"},
54
56
  {"id": "article", "name": "自媒体文章", "icon": "i-news", "engine": "review", "builtin": True,
55
57
  "manuscript": "article.md",
56
58
  "rubric": ["选题与标题", "开头吸引力", "结构节奏", "平台适配", "传播性"],
@@ -296,11 +298,13 @@ def flow_drift(task):
296
298
  return {"flow": cur.get("id") or "", "pinned": pinned, "current": current}
297
299
 
298
300
 
299
- def upsert_flow(payload):
301
+ def upsert_flow(payload, source="manual"):
300
302
  """新增/更新流程。返回 (flow, 错误)。
301
303
 
302
304
  - 预置流程 id:写入 overrides(可编辑、可恢复默认);
303
- - 新 id:创建自定义流程——**只需 name + engine**,其余字段留空走引擎默认。
305
+ - 新 id:创建自定义流程——**只需 name + engine**,其余字段留空走引擎默认;
306
+ - source:覆盖成因,进版本历史(manual=手动改 / import=分享码导入 /
307
+ restore=恢复操作顶下旧版);语义与名字都相同则不产生历史噪音。
304
308
  """
305
309
  fid = str(payload.get("id") or "").strip()
306
310
  name = str(payload.get("name") or "").strip()
@@ -319,6 +323,7 @@ def upsert_flow(payload):
319
323
  ov.pop("id", None)
320
324
  ov["engine"] = base["engine"] # 引擎不可改(避免语义错乱)
321
325
  merged = _apply_overrides(base, ov)
326
+ _push_history(fid, merged, source)
322
327
  with _LOCK:
323
328
  data = _read()
324
329
  ovs = data.get("overrides") if isinstance(data.get("overrides"), dict) else {}
@@ -362,6 +367,7 @@ def upsert_flow(payload):
362
367
  elif engine == "code":
363
368
  flow["verify_command"] = str(payload.get("verify_command") or "")[:200]
364
369
  # direct:无流程参数(目标+附件即全部输入)
370
+ _push_history(fid, flow, source)
365
371
  with _LOCK:
366
372
  data = _read()
367
373
  flows = data.get("flows") if isinstance(data.get("flows"), list) else []
@@ -399,3 +405,222 @@ def reset_flow(flow_id):
399
405
  data["overrides"] = ovs
400
406
  _write(data)
401
407
  return None
408
+
409
+
410
+ # ---- 流程分享码(参考 Dify 模板中心 / n8n workflow 模板的可分发思路)----
411
+ # 把「流程」变成可分发资产:导出为一段可粘贴的分享码,对方在任务类型管理里
412
+ # 粘贴导入即装。码体 = 前缀 + urlsafe base64(JSON),无签名——分享码本来就
413
+ # 是明文可读的信封,导入时过 upsert_flow 全套规范化,坏数据进不来。
414
+
415
+ _SHARE_PREFIX = "CBFLOW1."
416
+ _SHARE_KIND = "codebee-flow"
417
+ # 导出字段白名单:与 _EDITABLE 对齐 + 身份/展示字段。builtin 标记不导出
418
+ # (对端是否预置由对端的 BUILTIN_FLOWS 决定,不能跨机携带)。
419
+ _SHARE_FIELDS = ("id", "name", "engine", "icon", "goal_hint", "note",
420
+ "manuscript", "rubric", "threshold", "rounds", "best_of",
421
+ "serial", "draft_prompt", "critique_prompt", "verify_command")
422
+
423
+
424
+ def export_flow_code(flow_id):
425
+ """导出流程为分享码。返回 (code, 错误)。"""
426
+ f = get_flow(str(flow_id or "").strip())
427
+ if not f:
428
+ return None, "流程不存在"
429
+ payload = {"kind": _SHARE_KIND, "v": 1,
430
+ "flow": {k: f.get(k) for k in _SHARE_FIELDS if k in f}}
431
+ blob = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
432
+ return _SHARE_PREFIX + base64.urlsafe_b64encode(blob.encode("utf-8")).decode("ascii"), None
433
+
434
+
435
+ def _decode_share_code(code):
436
+ """分享码 → (flow_in, 错误)。解码与校验共用(preview / import 两步走)。"""
437
+ if not isinstance(code, str) or not code.strip().startswith(_SHARE_PREFIX):
438
+ return None, "不是有效的 CodeBee 流程分享码(应以 %s 开头)" % _SHARE_PREFIX
439
+ raw = code.strip()[len(_SHARE_PREFIX):].strip()
440
+ try:
441
+ blob = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
442
+ payload = json.loads(blob.decode("utf-8"))
443
+ except Exception:
444
+ return None, "分享码解析失败(可能被截断或篡改)"
445
+ if not isinstance(payload, dict) or payload.get("kind") != _SHARE_KIND:
446
+ return None, "分享码内容不是 CodeBee 流程"
447
+ flow_in = payload.get("flow")
448
+ if not isinstance(flow_in, dict):
449
+ return None, "分享码缺少流程定义"
450
+ if not str(flow_in.get("id") or "").strip():
451
+ return None, "分享码缺少流程 ID"
452
+ return flow_in, None
453
+
454
+
455
+ def preview_flow_code(code):
456
+ """分享码两步导入的第一步:解析并预览,不落盘。
457
+
458
+ 返回 (预览, 错误):预览带流程定义摘要 + 与现有同 ID 流程的冲突提示。"""
459
+ flow_in, err = _decode_share_code(code)
460
+ if err:
461
+ return None, err
462
+ fid = str(flow_in.get("id") or "").strip()
463
+ existed = get_flow(fid)
464
+ preview = {"id": fid,
465
+ "name": str(flow_in.get("name") or "")[:20],
466
+ "engine": str(flow_in.get("engine") or "review"),
467
+ "rubric": flow_in.get("rubric") if isinstance(flow_in.get("rubric"), list) else [],
468
+ "threshold": flow_in.get("threshold"),
469
+ "rounds": flow_in.get("rounds"),
470
+ "has_prompts": bool(str(flow_in.get("draft_prompt") or "").strip()
471
+ or str(flow_in.get("critique_prompt") or "").strip()),
472
+ "serial": bool(isinstance(flow_in.get("serial"), dict)
473
+ and flow_in.get("serial", {}).get("chapters")),
474
+ "exists": bool(existed),
475
+ "existing_name": (existed or {}).get("name") or "",
476
+ "builtin": bool(existed and existed.get("builtin"))}
477
+ return preview, None
478
+
479
+
480
+ def import_flow_code(code):
481
+ """导入分享码。返回 (result, 错误);result = {id, name, status, builtin}。
482
+
483
+ status:created=新建自定义流程;updated=覆盖已有定义(预置流程写
484
+ overrides,可「恢复默认」回滚);noop=内容与现状完全一致。UI 走
485
+ preview_flow_code 预览确认后再进这里。"""
486
+ flow_in, err = _decode_share_code(code)
487
+ if err:
488
+ return None, err
489
+ fid = str(flow_in.get("id") or "").strip()
490
+ existed = get_flow(fid)
491
+ existed_digest = flow_digest(existed) if existed else ""
492
+ flow, err = upsert_flow(flow_in, source="import")
493
+ if err:
494
+ return None, err
495
+ if existed and flow_digest(flow) == existed_digest \
496
+ and flow.get("name") == existed.get("name"):
497
+ status = "noop"
498
+ else:
499
+ status = "updated" if existed else "created"
500
+ return {"id": fid, "name": flow.get("name"), "status": status,
501
+ "builtin": bool(existed and existed.get("builtin"))}, None
502
+
503
+
504
+ # ---- 版本历史(Langfuse prompt management 借鉴):提示词/参数改动可回滚 ----
505
+ # 覆盖前的当前版自动快照进 data/flows_history.json(每流程 ≤_HISTORY_MAX 条),
506
+ # 恢复=把历史版重新 upsert 回来;runs 已按 flow_revision(digest 前 16)对账,
507
+ # 每版顺手给出实测战绩——「哪版提示词好用」不再靠感觉。
508
+
509
+ _HISTORY_MAX = 10 # 每流程保留的历史版本数(新的顶旧的)
510
+
511
+
512
+ def _history_read():
513
+ try:
514
+ data = json.loads((paths.DATA_DIR / "flows_history.json").read_text(encoding="utf-8"))
515
+ return data if isinstance(data, dict) else {}
516
+ except Exception:
517
+ return {}
518
+
519
+
520
+ def _history_write(data):
521
+ paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
522
+ tmp = paths.DATA_DIR / "flows_history.json.tmp"
523
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
524
+ tmp.replace(paths.DATA_DIR / "flows_history.json")
525
+
526
+
527
+ def _push_history(fid, new_flow, source):
528
+ """把「即将被覆盖的当前版」存进历史;语义与名字都相同则跳过(不产噪音)。"""
529
+ try:
530
+ cur = get_flow(fid)
531
+ if not cur:
532
+ return # 首次创建没有旧版可存
533
+ if flow_digest(cur) == flow_digest(new_flow) \
534
+ and cur.get("name") == new_flow.get("name"):
535
+ return
536
+ with _LOCK:
537
+ data = _history_read()
538
+ versions = data.get(fid) if isinstance(data.get(fid), list) else []
539
+ versions.insert(0, {"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
540
+ "source": str(source or "manual")[:16],
541
+ "flow": {k: cur.get(k) for k in _SHARE_FIELDS if k in cur}})
542
+ data[fid] = versions[:_HISTORY_MAX]
543
+ _history_write(data)
544
+ except Exception:
545
+ pass # 历史是增强,永远不挡保存
546
+
547
+
548
+ _RUNS_PROVIDER = None # main.py 启动注入 store.list_runs;flows(L0) 不反向
549
+ # 依赖 store(L1)——静态图不成环(同 set_run_estimator 模式)
550
+
551
+
552
+ def set_runs_provider(fn):
553
+ """注入 run 遍历器(应用入口传 store.list_runs);未注入时版本无战绩。"""
554
+ global _RUNS_PROVIDER
555
+ _RUNS_PROVIDER = fn if callable(fn) else None
556
+
557
+
558
+ def _flow_stats(digest16):
559
+ """该流程修订的实测战绩:任务数/通过率/均分(按 run 钉的 flow_revision 对账)。
560
+
561
+ 均分分母只算有评分的 run;无记录返回 None。"""
562
+ try:
563
+ if not _RUNS_PROVIDER:
564
+ return None
565
+ runs = wins = scored_n = 0
566
+ total = 0.0
567
+ for run in (_RUNS_PROVIDER(limit=None) or []):
568
+ if not isinstance(run, dict):
569
+ continue
570
+ rev = str(((run.get("execution_snapshot") or {}).get("flow_revision")) or "")
571
+ if rev[:16] != digest16:
572
+ continue
573
+ runs += 1
574
+ v = run.get("verdict") or {}
575
+ if v.get("publishable") is True:
576
+ wins += 1
577
+ if isinstance(v.get("overall"), (int, float)):
578
+ total += float(v["overall"])
579
+ scored_n += 1
580
+ if not runs:
581
+ return None
582
+ return {"runs": runs, "pass_rate": round(wins / runs, 2),
583
+ "avg_overall": round(total / scored_n, 1) if scored_n else None}
584
+ except Exception:
585
+ return None
586
+
587
+
588
+ def flow_versions(flow_id):
589
+ """给 UI 的版本清单:当前版在前,历史版新→旧;每版带 digest 与实测战绩。"""
590
+ fid = str(flow_id or "").strip()
591
+ out = []
592
+ cur = get_flow(fid)
593
+ if cur:
594
+ d = flow_digest(cur)[:16]
595
+ out.append({"current": True, "ts": "", "source": "current", "name": cur.get("name"),
596
+ "digest": d, "stats": _flow_stats(d)})
597
+ for snap in _history_read().get(fid) or []:
598
+ d = flow_digest(snap.get("flow") or {})[:16]
599
+ out.append({"current": False, "ts": str(snap.get("ts") or ""),
600
+ "source": str(snap.get("source") or "manual"),
601
+ "name": (snap.get("flow") or {}).get("name"),
602
+ "digest": d, "stats": _flow_stats(d)})
603
+ return out
604
+
605
+
606
+ def restore_flow_version(flow_id, ts):
607
+ """恢复历史版本。返回 (flow, 错误)。
608
+
609
+ 恢复动作本身也会把「被顶下来的当前版」存进历史(source=restore),
610
+ 恢复错了还能再恢复回去;目标条目先从历史中消费掉再做 upsert——
611
+ ts 是秒级精度,顺序反了同秒快照会被误删。"""
612
+ fid = str(flow_id or "").strip()
613
+ ts = str(ts or "")
614
+ with _LOCK:
615
+ data = _history_read()
616
+ versions = data.get(fid) if isinstance(data.get(fid), list) else []
617
+ snap = next((s for s in versions if s.get("ts") == ts), None)
618
+ if snap:
619
+ data[fid] = [s for s in versions if s.get("ts") != ts]
620
+ _history_write(data)
621
+ if not snap:
622
+ return None, "历史版本不存在(可能已被消费)"
623
+ flow, err = upsert_flow(dict(snap.get("flow") or {}), source="restore")
624
+ if err:
625
+ return None, err
626
+ return flow, None
@@ -1,168 +1,168 @@
1
- # -*- coding: utf-8 -*-
2
- """Goal 状态外置:跨轮目标(title/描述/phase/轮次)独立于 LLM 调用持久化。
3
-
4
- 设计稿:docs/migration/03-state-externalization.md §2A。
5
- 参考 dsh packages/goal/goal/:状态 100% 来自事件溯源(goal/change 事件),
6
- 唯一当前 goal,revision 走 CAS 拒绝陈旧引用。
7
-
8
- 与 planner.py 的关系:planner 继续负责「LLM 拆解」;GoalService 只管
9
- 「目标状态」——谁都可以读(UI/续行驱动器/流水线),变更必须过 CAS。
10
- """
11
- from __future__ import annotations
12
-
13
- import json
14
- import threading
15
- import time
16
- import uuid
17
- from pathlib import Path
18
-
19
- _LOCK = threading.RLock()
20
- # goal_id -> dict(内存缓存;磁盘为唯一事实来源)
21
- _GOALS = {}
22
- _FILE = None # 由 init() 注入
23
-
24
-
25
- def init(data_dir=None):
26
- """注入存储路径并从磁盘加载。测试可传临时目录。"""
27
- global _FILE, _GOALS
28
- with _LOCK:
29
- base = Path(data_dir) if data_dir else Path(_default_dir())
30
- base.mkdir(parents=True, exist_ok=True)
31
- _FILE = base / "goals.json"
32
- _GOALS = {}
33
- try:
34
- data = json.loads(_FILE.read_text(encoding="utf-8"))
35
- for g in data.get("goals") or []:
36
- if isinstance(g, dict) and g.get("goal_id"):
37
- _GOALS[g["goal_id"]] = g
38
- except (OSError, json.JSONDecodeError):
39
- pass
40
-
41
-
42
- def _default_dir():
43
- from . import paths
44
- return str(paths.DATA_DIR)
45
-
46
-
47
- def _persist():
48
- """原子写盘(tmp + replace)。调用方必须已持有 _LOCK。"""
49
- if _FILE is None:
50
- return
51
- tmp = _FILE.with_suffix(".tmp")
52
- tmp.write_text(json.dumps({"goals": list(_GOALS.values())},
53
- ensure_ascii=False, indent=2), encoding="utf-8")
54
- tmp.replace(_FILE)
55
-
56
-
57
- class StaleRevisionError(Exception):
58
- """CAS 失败:调用方持有的是陈旧 revision。"""
59
-
60
-
61
- class GoalExistsError(Exception):
62
- """已有未完结的当前 goal,需先 complete/abandon。"""
63
-
64
-
65
- class GoalNotFoundError(Exception):
66
- pass
67
-
68
-
69
- def _valid_phase(p):
70
- return p in ("active", "paused", "complete", "abandoned")
71
-
72
-
73
- def current():
74
- """返回唯一「当前」goal(未 complete/abandoned 的最新一个);无则 None。"""
75
- with _LOCK:
76
- open_goals = [g for g in _GOALS.values()
77
- if g.get("phase") in ("active", "paused")]
78
- if not open_goals:
79
- return None
80
- return max(open_goals, key=lambda g: g.get("created_at", 0))
81
-
82
-
83
- def get(goal_id: str):
84
- with _LOCK:
85
- g = _GOALS.get(goal_id)
86
- return dict(g) if g else None
87
-
88
-
89
- def create(title: str, description: str = "", *, rounds_max: int | None = None,
90
- metadata: dict | None = None) -> dict:
91
- """创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。
92
-
93
- rounds_max 缺省读 settings_v2 orchestrator.max_goal_rounds(读不到回落 5)。"""
94
- if rounds_max is None:
95
- try:
96
- from .settings_schema import get as ss_get, register_default_namespaces
97
- register_default_namespaces()
98
- rounds_max = int(ss_get("orchestrator", "max_goal_rounds") or 5)
99
- except Exception:
100
- rounds_max = 5
101
- with _LOCK:
102
- cur = current()
103
- if cur is not None:
104
- raise GoalExistsError(
105
- "已有进行中的 goal %s(%s),请先 complete/abandon"
106
- % (cur["goal_id"], cur["title"]))
107
- goal = {
108
- "goal_id": "g" + uuid.uuid4().hex[:12],
109
- "title": (title or "").strip(),
110
- "description": (description or "").strip(),
111
- "phase": "active",
112
- "revision": 1,
113
- "rounds_started": 0,
114
- "rounds_max": max(1, int(rounds_max)),
115
- "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
116
- "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
117
- "metadata": dict(metadata or {}),
118
- }
119
- _GOALS[goal["goal_id"]] = goal
120
- _persist()
121
- return dict(goal)
122
-
123
-
124
- def update(goal_id: str, expected_revision: int, **changes) -> dict:
125
- """CAS 更新:expected_revision 不匹配即抛 StaleRevisionError。
126
-
127
- 允许变更的字段:title/description/phase/rounds_max/metadata。
128
- phase 只允许合法值;complete/abandoned 为终态不可再改回。
129
- """
130
- with _LOCK:
131
- g = _GOALS.get(goal_id)
132
- if not g:
133
- raise GoalNotFoundError(goal_id)
134
- if g["revision"] != expected_revision:
135
- raise StaleRevisionError(
136
- "%s 期望 rev %d,实际 %d" % (goal_id, expected_revision, g["revision"]))
137
- if g["phase"] in ("complete", "abandoned"):
138
- raise ValueError("goal 已终态(%s),不可再变更" % g["phase"])
139
- allowed = {"title", "description", "phase", "rounds_max",
140
- "rounds_started", "metadata"}
141
- for k, v in changes.items():
142
- if k not in allowed:
143
- raise ValueError("不可变更字段:%s" % k)
144
- if k == "phase" and not _valid_phase(v):
145
- raise ValueError("非法 phase:%s" % v)
146
- if k == "rounds_max":
147
- v = max(1, int(v))
148
- g[k] = v
149
- g["revision"] += 1
150
- g["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
151
- _persist()
152
- return dict(g)
153
-
154
-
155
- def increment_rounds(goal_id: str, expected_revision: int) -> dict:
156
- """轮次 +1(Goal Round Driver 专用 CAS 入口)。"""
157
- with _LOCK:
158
- g = _GOALS.get(goal_id)
159
- if not g:
160
- raise GoalNotFoundError(goal_id)
161
- return update(goal_id, expected_revision, rounds_started=g["rounds_started"] + 1)
162
-
163
-
164
- def list_goals(limit: int = 50) -> list:
165
- with _LOCK:
166
- goals = sorted(_GOALS.values(),
167
- key=lambda g: g.get("created_at", ""), reverse=True)
1
+ # -*- coding: utf-8 -*-
2
+ """Goal 状态外置:跨轮目标(title/描述/phase/轮次)独立于 LLM 调用持久化。
3
+
4
+ 设计稿:docs/migration/03-state-externalization.md §2A。
5
+ 参考 dsh packages/goal/goal/:状态 100% 来自事件溯源(goal/change 事件),
6
+ 唯一当前 goal,revision 走 CAS 拒绝陈旧引用。
7
+
8
+ 与 planner.py 的关系:planner 继续负责「LLM 拆解」;GoalService 只管
9
+ 「目标状态」——谁都可以读(UI/续行驱动器/流水线),变更必须过 CAS。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ _LOCK = threading.RLock()
20
+ # goal_id -> dict(内存缓存;磁盘为唯一事实来源)
21
+ _GOALS = {}
22
+ _FILE = None # 由 init() 注入
23
+
24
+
25
+ def init(data_dir=None):
26
+ """注入存储路径并从磁盘加载。测试可传临时目录。"""
27
+ global _FILE, _GOALS
28
+ with _LOCK:
29
+ base = Path(data_dir) if data_dir else Path(_default_dir())
30
+ base.mkdir(parents=True, exist_ok=True)
31
+ _FILE = base / "goals.json"
32
+ _GOALS = {}
33
+ try:
34
+ data = json.loads(_FILE.read_text(encoding="utf-8"))
35
+ for g in data.get("goals") or []:
36
+ if isinstance(g, dict) and g.get("goal_id"):
37
+ _GOALS[g["goal_id"]] = g
38
+ except (OSError, json.JSONDecodeError):
39
+ pass
40
+
41
+
42
+ def _default_dir():
43
+ from . import paths
44
+ return str(paths.DATA_DIR)
45
+
46
+
47
+ def _persist():
48
+ """原子写盘(tmp + replace)。调用方必须已持有 _LOCK。"""
49
+ if _FILE is None:
50
+ return
51
+ tmp = _FILE.with_suffix(".tmp")
52
+ tmp.write_text(json.dumps({"goals": list(_GOALS.values())},
53
+ ensure_ascii=False, indent=2), encoding="utf-8")
54
+ tmp.replace(_FILE)
55
+
56
+
57
+ class StaleRevisionError(Exception):
58
+ """CAS 失败:调用方持有的是陈旧 revision。"""
59
+
60
+
61
+ class GoalExistsError(Exception):
62
+ """已有未完结的当前 goal,需先 complete/abandon。"""
63
+
64
+
65
+ class GoalNotFoundError(Exception):
66
+ pass
67
+
68
+
69
+ def _valid_phase(p):
70
+ return p in ("active", "paused", "complete", "abandoned")
71
+
72
+
73
+ def current():
74
+ """返回唯一「当前」goal(未 complete/abandoned 的最新一个);无则 None。"""
75
+ with _LOCK:
76
+ open_goals = [g for g in _GOALS.values()
77
+ if g.get("phase") in ("active", "paused")]
78
+ if not open_goals:
79
+ return None
80
+ return max(open_goals, key=lambda g: g.get("created_at", 0))
81
+
82
+
83
+ def get(goal_id: str):
84
+ with _LOCK:
85
+ g = _GOALS.get(goal_id)
86
+ return dict(g) if g else None
87
+
88
+
89
+ def create(title: str, description: str = "", *, rounds_max: int | None = None,
90
+ metadata: dict | None = None) -> dict:
91
+ """创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。
92
+
93
+ rounds_max 缺省读 settings_v2 orchestrator.max_goal_rounds(读不到回落 5)。"""
94
+ if rounds_max is None:
95
+ try:
96
+ from .settings_schema import get as ss_get, register_default_namespaces
97
+ register_default_namespaces()
98
+ rounds_max = int(ss_get("orchestrator", "max_goal_rounds") or 5)
99
+ except Exception:
100
+ rounds_max = 5
101
+ with _LOCK:
102
+ cur = current()
103
+ if cur is not None:
104
+ raise GoalExistsError(
105
+ "已有进行中的 goal %s(%s),请先 complete/abandon"
106
+ % (cur["goal_id"], cur["title"]))
107
+ goal = {
108
+ "goal_id": "g" + uuid.uuid4().hex[:12],
109
+ "title": (title or "").strip(),
110
+ "description": (description or "").strip(),
111
+ "phase": "active",
112
+ "revision": 1,
113
+ "rounds_started": 0,
114
+ "rounds_max": max(1, int(rounds_max)),
115
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
116
+ "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
117
+ "metadata": dict(metadata or {}),
118
+ }
119
+ _GOALS[goal["goal_id"]] = goal
120
+ _persist()
121
+ return dict(goal)
122
+
123
+
124
+ def update(goal_id: str, expected_revision: int, **changes) -> dict:
125
+ """CAS 更新:expected_revision 不匹配即抛 StaleRevisionError。
126
+
127
+ 允许变更的字段:title/description/phase/rounds_max/metadata。
128
+ phase 只允许合法值;complete/abandoned 为终态不可再改回。
129
+ """
130
+ with _LOCK:
131
+ g = _GOALS.get(goal_id)
132
+ if not g:
133
+ raise GoalNotFoundError(goal_id)
134
+ if g["revision"] != expected_revision:
135
+ raise StaleRevisionError(
136
+ "%s 期望 rev %d,实际 %d" % (goal_id, expected_revision, g["revision"]))
137
+ if g["phase"] in ("complete", "abandoned"):
138
+ raise ValueError("goal 已终态(%s),不可再变更" % g["phase"])
139
+ allowed = {"title", "description", "phase", "rounds_max",
140
+ "rounds_started", "metadata"}
141
+ for k, v in changes.items():
142
+ if k not in allowed:
143
+ raise ValueError("不可变更字段:%s" % k)
144
+ if k == "phase" and not _valid_phase(v):
145
+ raise ValueError("非法 phase:%s" % v)
146
+ if k == "rounds_max":
147
+ v = max(1, int(v))
148
+ g[k] = v
149
+ g["revision"] += 1
150
+ g["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
151
+ _persist()
152
+ return dict(g)
153
+
154
+
155
+ def increment_rounds(goal_id: str, expected_revision: int) -> dict:
156
+ """轮次 +1(Goal Round Driver 专用 CAS 入口)。"""
157
+ with _LOCK:
158
+ g = _GOALS.get(goal_id)
159
+ if not g:
160
+ raise GoalNotFoundError(goal_id)
161
+ return update(goal_id, expected_revision, rounds_started=g["rounds_started"] + 1)
162
+
163
+
164
+ def list_goals(limit: int = 50) -> list:
165
+ with _LOCK:
166
+ goals = sorted(_GOALS.values(),
167
+ key=lambda g: g.get("created_at", ""), reverse=True)
168
168
  return [dict(g) for g in goals[:limit]]