codebee 0.1.66 → 0.1.68
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 +543 -520
- package/LICENSE +21 -21
- package/README.md +11 -7
- 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/acceptance.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -0
- package/app/core/attachments.py +623 -532
- package/app/core/automation.py +758 -758
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +930 -796
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1234 -1234
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +42 -0
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -0
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +173 -173
- package/app/core/dispatch_log.py +128 -113
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +23 -23
- package/app/core/errorlog.py +137 -137
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flows.py +48 -2
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -374
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +46 -26
- package/app/core/knowledge.py +57 -3
- package/app/core/manager.py +2122 -2115
- package/app/core/market.py +362 -362
- package/app/core/market_remote.py +124 -26
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +51 -3
- package/app/core/notify.py +106 -106
- package/app/core/operations.py +228 -0
- package/app/core/paihang.py +138 -109
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +221 -12
- package/app/core/planner.py +6 -1
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +367 -367
- package/app/core/publish/browser.py +539 -533
- package/app/core/publish/fanqie.py +159 -128
- package/app/core/publish/flow.py +440 -432
- package/app/core/publish/flows-fanqie-calibrated.json +332 -332
- package/app/core/publish/flows-qimao-calibrated.json +7 -3
- package/app/core/publish/ledger.py +203 -198
- package/app/core/publish/manager.py +692 -623
- package/app/core/publish/qimao.py +141 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/revisions.py +51 -0
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2067 -2050
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +149 -149
- package/app/core/settings_schema.py +262 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +122 -114
- package/app/core/skillpacks/fanqie-novel.md +80 -80
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skills.py +109 -2
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +278 -41
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +144 -0
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +862 -858
- package/app/core/volumes.py +389 -355
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2465 -2447
- package/app/main.py +2981 -2838
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +14399 -13994
- package/app/ui/artdiff.js +74 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +2770 -2716
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +6 -0
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +96 -24
- package/bin/tutti.js +147 -147
- package/package.json +46 -46
- package/tools/wx_reader.py +261 -261
package/app/core/flows.py
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import hashlib
|
|
12
13
|
import json
|
|
13
14
|
import re
|
|
14
15
|
import threading
|
|
@@ -76,7 +77,10 @@ BUILTIN_FLOWS = [
|
|
|
76
77
|
"note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
|
|
77
78
|
{"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
|
|
78
79
|
"goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
|
|
79
|
-
"note": "
|
|
80
|
+
"note": "抓取七猫/番茄/起点/纵横四平台排行榜公开数据 → AI 提炼跨平台热门题材/人设/差异化切入点(快档直出报告)"},
|
|
81
|
+
{"id": "defect_retro", "name": "缺陷复盘", "icon": "i-history", "engine": "direct", "builtin": True,
|
|
82
|
+
"goal_hint": "复盘哪个版本/时间段的缺陷(建议附带禅道或 Jira 导出 CSV)",
|
|
83
|
+
"note": "读取附带的缺陷导出数据 → AI 输出产品/开发/测试三视角复盘报告:整体画像、根因聚类、漏测分析、改进动作(快档直出报告)"},
|
|
80
84
|
{"id": "research", "name": "调研报告", "icon": "i-file-search", "engine": "review", "builtin": True,
|
|
81
85
|
"manuscript": "report.md",
|
|
82
86
|
"rubric": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
|
|
@@ -113,7 +117,7 @@ BUILTIN_FLOWS = [
|
|
|
113
117
|
"threshold": 7.0, "rounds": 2,
|
|
114
118
|
"goal_hint": "目标岗位 + 个人经历(可贴旧简历附件),几年经验投什么职级",
|
|
115
119
|
"note": "简历优化/定制:起草 → 多维评审 → 修订循环 → 发布门禁"},
|
|
116
|
-
{"id": "bid_doc", "name": "标书编制", "icon": "i-
|
|
120
|
+
{"id": "bid_doc", "name": "标书编制", "icon": "i-tasks", "engine": "review", "builtin": True,
|
|
117
121
|
"manuscript": "bid.md",
|
|
118
122
|
"rubric": ["应答完整性", "合规符合度", "方案针对性", "评分点覆盖", "商务清晰度"],
|
|
119
123
|
"threshold": 7.5, "rounds": 2,
|
|
@@ -250,6 +254,48 @@ def get_flow(flow_id):
|
|
|
250
254
|
return None
|
|
251
255
|
|
|
252
256
|
|
|
257
|
+
# 参与 digest 的语义字段:会进运行链的参数与提示词。icon/note/goal_hint/name 是
|
|
258
|
+
# 纯展示位——措辞变化不影响执行语义,不应制造「修订漂移」噪音。
|
|
259
|
+
_DIGEST_FIELDS = ("id", "engine", "manuscript", "rubric", "threshold", "rounds",
|
|
260
|
+
"best_of", "serial", "draft_prompt", "critique_prompt",
|
|
261
|
+
"verify_command")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def flow_content_sha256(flow):
|
|
265
|
+
"""Return the full semantic content hash for an immutable flow revision."""
|
|
266
|
+
if not isinstance(flow, dict):
|
|
267
|
+
return ""
|
|
268
|
+
basis = {k: flow.get(k) for k in _DIGEST_FIELDS}
|
|
269
|
+
blob = json.dumps(basis, ensure_ascii=False, sort_keys=True, default=str)
|
|
270
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def flow_digest(flow):
|
|
274
|
+
"""流程定义的内容指纹(sha256 前 16 位)——「这个任务当时用的是哪一版流程」
|
|
275
|
+
的对账真源(借鉴 WorkDSH ADR-0010 发布后不可变修订)。"""
|
|
276
|
+
return flow_content_sha256(flow)[:16]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def flow_drift(task):
|
|
280
|
+
"""任务流程修订漂移检测:任务创建时钉的 digest vs 当前流程定义。
|
|
281
|
+
|
|
282
|
+
任务参数在创建时已固化(改流程不影响固化字段),但 task_compile 对历史
|
|
283
|
+
任务缺失字段(threshold/rubric/manuscript)仍回落当前流程定义——续跑/
|
|
284
|
+
重试因此可能静默吃到新流程。这里只诊断不裁定:返回
|
|
285
|
+
{"flow", "pinned", "current"} 或 None(无快照/流程已删/无漂移)。"""
|
|
286
|
+
snap = (task or {}).get("flow_snapshot") or {}
|
|
287
|
+
pinned = str(snap.get("digest") or "")
|
|
288
|
+
if not pinned:
|
|
289
|
+
return None
|
|
290
|
+
cur = get_flow(str((task or {}).get("type") or ""))
|
|
291
|
+
if not cur:
|
|
292
|
+
return None
|
|
293
|
+
current = flow_digest(cur)
|
|
294
|
+
if not current or current == pinned:
|
|
295
|
+
return None
|
|
296
|
+
return {"flow": cur.get("id") or "", "pinned": pinned, "current": current}
|
|
297
|
+
|
|
298
|
+
|
|
253
299
|
def upsert_flow(payload):
|
|
254
300
|
"""新增/更新流程。返回 (flow, 错误)。
|
|
255
301
|
|
package/app/core/goal_service.py
CHANGED
|
@@ -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]]
|