codebee 0.1.74 → 0.1.77

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 (119) hide show
  1. package/CHANGELOG.md +628 -607
  2. package/LICENSE +21 -21
  3. package/README.md +507 -508
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/analytics.py +38 -38
  14. package/app/core/artifacts.py +148 -148
  15. package/app/core/assets.py +110 -110
  16. package/app/core/attachments.py +623 -623
  17. package/app/core/audit.py +30 -30
  18. package/app/core/backup.py +470 -470
  19. package/app/core/beekeeper.py +183 -183
  20. package/app/core/benchstore.py +95 -95
  21. package/app/core/board.py +209 -209
  22. package/app/core/bookmeta.py +1036 -921
  23. package/app/core/bookmeta_catalog.py +180 -180
  24. package/app/core/branching.py +137 -137
  25. package/app/core/builtin_agent.py +1680 -1680
  26. package/app/core/capability.py +144 -144
  27. package/app/core/chaptersafe.py +32 -32
  28. package/app/core/cleanup.py +303 -303
  29. package/app/core/compaction.py +201 -201
  30. package/app/core/contracts.py +255 -255
  31. package/app/core/covergen.py +356 -356
  32. package/app/core/defectretro.py +43 -43
  33. package/app/core/diagnostics.py +114 -114
  34. package/app/core/dispatch.py +190 -190
  35. package/app/core/dispatch_log.py +128 -128
  36. package/app/core/env_scrub.py +85 -85
  37. package/app/core/errorlog.py +137 -137
  38. package/app/core/evaluation.py +174 -174
  39. package/app/core/failure_notify.py +290 -290
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +389 -387
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +22 -0
  45. package/app/core/manager.py +2232 -2232
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +189 -189
  49. package/app/core/openapi.py +154 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +189 -18
  53. package/app/core/planner.py +88 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/browser.py +655 -655
  60. package/app/core/publish/fanqie.py +165 -165
  61. package/app/core/publish/flow.py +640 -640
  62. package/app/core/publish/flows-fanqie-calibrated.json +336 -336
  63. package/app/core/publish/manager.py +1118 -1118
  64. package/app/core/publish/qimao.py +176 -176
  65. package/app/core/publish/ws.py +139 -139
  66. package/app/core/quality_gate.py +194 -194
  67. package/app/core/redact.py +38 -38
  68. package/app/core/repeat_guard.py +129 -129
  69. package/app/core/retrieval.py +137 -137
  70. package/app/core/router.py +398 -398
  71. package/app/core/runner.py +2238 -2225
  72. package/app/core/selfupdate.py +384 -384
  73. package/app/core/session_log.py +161 -161
  74. package/app/core/sessions.py +312 -312
  75. package/app/core/settings_schema.py +270 -270
  76. package/app/core/share_page.py +114 -114
  77. package/app/core/skill_scan.py +139 -139
  78. package/app/core/skillpacks/market/character-bible.md +66 -66
  79. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  80. package/app/core/skillpacks/market/git-workflow.md +57 -57
  81. package/app/core/skillpacks/market/release-notes.md +72 -72
  82. package/app/core/skillpacks/market/weekly-report.md +71 -71
  83. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  84. package/app/core/skills.py +1 -1
  85. package/app/core/step_runner.py +132 -132
  86. package/app/core/story_tracking.py +275 -241
  87. package/app/core/task_compile.py +199 -199
  88. package/app/core/telemetry.py +292 -292
  89. package/app/core/tlsctx.py +64 -64
  90. package/app/core/token_meter.py +147 -147
  91. package/app/core/tracing.py +144 -144
  92. package/app/core/upstream.py +41 -41
  93. package/app/core/usage.py +1035 -1035
  94. package/app/core/volumes.py +389 -389
  95. package/app/core/wxdigest.py +858 -858
  96. package/app/core/zentao.py +2692 -2692
  97. package/app/main.py +59 -0
  98. package/app/mcp_server.py +244 -244
  99. package/app/output/_createbook_body.json +1 -0
  100. package/app/output/_probe_fq_click.py +50 -0
  101. package/app/output/_probe_fq_form.py +42 -0
  102. package/app/output/_probe_fq_modal.py +55 -0
  103. package/app/output/_probe_fq_points.py +71 -0
  104. package/app/output/_probe_fq_submit.py +34 -0
  105. package/app/output/_probe_fq_watch.py +62 -0
  106. package/app/pick_dialog.py +110 -110
  107. package/app/ui/app.js +108 -12
  108. package/app/ui/artdiff.js +74 -74
  109. package/app/ui/board.html +162 -162
  110. package/app/ui/board.js +305 -305
  111. package/app/ui/i18n.js +15 -0
  112. package/app/ui/icons/bee.svg +79 -79
  113. package/app/ui/index.html +4 -0
  114. package/app/ui/manifest.json +16 -16
  115. package/app/ui/qrcode.js +2297 -2297
  116. package/app/ui/style.css +7086 -7070
  117. package/bin/tutti.js +147 -147
  118. package/package.json +48 -48
  119. package/tools/wx_reader.py +261 -261
@@ -1,271 +1,271 @@
1
- # -*- coding: utf-8 -*-
2
- """Schema 化设置:namespace 注册 + revision CAS + 脱敏 describe。
3
-
4
- 设计稿:docs/migration/05-model-seams.md §4C(按 Tutti 实际架构落地:
5
- 不替换现有 settings.py——它正被并行迭代且已有原子写+钳制;本模块提供
6
- 「schema 驱动 + revision CAS + secret 脱敏」能力,管理自己的
7
- data/settings_v2.json,供新增配置项使用,存量 key-value 不迁移)。
8
-
9
- 参考 dsh packages/settings/settings/src/index.ts:419-637:
10
- register(ns, schema, base) / mutate(ops) / describe({redactSecrets}) /
11
- SettingsConflictError(revision 单调,乐观并发)。
12
-
13
- 冲突语义:mutate 带 expected_revision 时做 CAS;不带则最后写赢。
14
- """
15
- from __future__ import annotations
16
-
17
- import json
18
- import re
19
- import threading
20
- from pathlib import Path
21
-
22
- _LOCK = threading.RLock()
23
- _FILE = None # init() 注入
24
- _NAMESPACES = {} # ns -> {"fields": {path: FieldDef}, "validate": fn}
25
- _VALUES = {} # ns -> {path: value}
26
- _REVISIONS = {} # ns -> int
27
-
28
-
29
- class SettingsConflictError(Exception):
30
- """CAS 失败:调用方持有的是陈旧 revision。"""
31
-
32
-
33
- class FieldDef:
34
- __slots__ = ("path", "ftype", "default", "description", "redact", "choices", "clamp")
35
-
36
- def __init__(self, path, ftype, default, description="", redact=False,
37
- choices=None, clamp=None):
38
- self.path = path
39
- self.ftype = ftype # "int" | "float" | "str" | "bool"
40
- self.default = default
41
- self.description = description
42
- self.redact = redact # True = describe() 输出 __REDACTED__
43
- self.choices = choices # 可选:合法值列表
44
- self.clamp = clamp # 可选:(min, max),int/float 用
45
-
46
-
47
- def init(data_dir=None):
48
- global _FILE, _NAMESPACES, _VALUES, _REVISIONS
49
- with _LOCK:
50
- base = Path(data_dir) if data_dir else Path(_default_dir())
51
- base.mkdir(parents=True, exist_ok=True)
52
- _FILE = base / "settings_v2.json"
53
- _NAMESPACES, _VALUES, _REVISIONS = {}, {}, {}
54
- try:
55
- data = json.loads(_FILE.read_text(encoding="utf-8"))
56
- _VALUES = data.get("values") or {}
57
- _REVISIONS = data.get("revisions") or {}
58
- except (OSError, json.JSONDecodeError):
59
- pass
60
-
61
-
62
- def _default_dir():
63
- from . import paths
64
- return str(paths.DATA_DIR)
65
-
66
-
67
- def _persist():
68
- if _FILE is None:
69
- return
70
- tmp = _FILE.with_suffix(".tmp")
71
- tmp.write_text(json.dumps({"values": _VALUES, "revisions": _REVISIONS},
72
- ensure_ascii=False, indent=2), encoding="utf-8")
73
- tmp.replace(_FILE)
74
-
75
-
76
- # ---- 路径读写(支持 "a.b" 点路径;不实现数组下标——Tutti 配置用不到) ----
77
-
78
- def _get_path(d, path):
79
- cur = d
80
- for seg in path.split("."):
81
- if not isinstance(cur, dict) or seg not in cur:
82
- return None
83
- cur = cur[seg]
84
- return cur
85
-
86
-
87
- def _set_path(d, path, value):
88
- segs = path.split(".")
89
- cur = d
90
- for seg in segs[:-1]:
91
- cur = cur.setdefault(seg, {})
92
- if not isinstance(cur, dict):
93
- raise ValueError("路径冲突:%s" % path)
94
- cur[segs[-1]] = value
95
-
96
-
97
- # ---- 注册 ----
98
-
99
- def register_namespace(ns, fields, validate=None):
100
- """注册 namespace 与字段 schema。已注册的 namespace 不可重复注册。"""
101
- with _LOCK:
102
- if ns in _NAMESPACES:
103
- raise ValueError("namespace 已注册:%s" % ns)
104
- fmap = {}
105
- for f in fields:
106
- fmap[f.path] = f
107
- if _get_path(_VALUES.get(ns, {}), f.path) is None:
108
- _VALUES.setdefault(ns, {})
109
- _set_path(_VALUES[ns], f.path, f.default)
110
- _NAMESPACES[ns] = {"fields": fmap, "validate": validate}
111
- _REVISIONS.setdefault(ns, 1)
112
- _persist()
113
-
114
-
115
- def coerce(field, value):
116
- """按字段类型强转 + 钳制 + choices 校验。返回 (规范化值, 错误)。"""
117
- try:
118
- if field.ftype == "int":
119
- v = int(value)
120
- elif field.ftype == "float":
121
- v = float(value)
122
- elif field.ftype == "bool":
123
- v = bool(value) if not isinstance(value, str) else (
124
- value.strip().lower() in ("1", "true", "yes", "on"))
125
- else:
126
- v = str(value)
127
- except (TypeError, ValueError):
128
- return None, "%s 必须是 %s" % (field.path, field.ftype)
129
- if field.clamp:
130
- lo, hi = field.clamp
131
- v = max(lo, min(hi, v))
132
- if field.choices is not None and v not in field.choices:
133
- return None, "%s 必须是 %s 之一" % (field.path, field.choices)
134
- return v, None
135
-
136
-
137
- # ---- 读写 ----
138
-
139
- def get(ns, path=None):
140
- with _LOCK:
141
- vals = _VALUES.get(ns)
142
- if vals is None:
143
- return None
144
- return _get_path(vals, path) if path else dict(vals)
145
-
146
-
147
- def revision(ns):
148
- with _LOCK:
149
- return _REVISIONS.get(ns, 0)
150
-
151
-
152
- def names():
153
- """已注册 namespace 清单(GET /api/settings-v2 用)。"""
154
- with _LOCK:
155
- return sorted(_NAMESPACES)
156
-
157
-
158
- def mutate(ns, ops, expected_revision=None):
159
- """写一组操作。ops = [{"op":"set","path":..., "value":...}, ...]。
160
-
161
- 值按注册的 FieldDef 强转/钳制/校验;namespace 有 validate 钩子时整体验证。
162
- expected_revision 非 None 时做 CAS。成功返回新 revision。
163
- """
164
- with _LOCK:
165
- ndef = _NAMESPACES.get(ns)
166
- if not ndef:
167
- raise ValueError("namespace 未注册:%s" % ns)
168
- if expected_revision is not None and _REVISIONS.get(ns) != expected_revision:
169
- raise SettingsConflictError(
170
- "%s 期望 rev %s,实际 %s" % (ns, expected_revision, _REVISIONS.get(ns)))
171
- # 先全部校验,后统一写入(部分失败不落盘)
172
- staged = []
173
- for op in ops:
174
- if op.get("op") != "set":
175
- raise ValueError("仅支持 set 操作:%r" % op.get("op"))
176
- path = op.get("path")
177
- fdef = ndef["fields"].get(path)
178
- if not fdef:
179
- raise ValueError("未注册字段:%s" % path)
180
- v, err = coerce(fdef, op.get("value"))
181
- if err:
182
- raise ValueError(err)
183
- staged.append((path, v))
184
- ns_values = dict(_VALUES.get(ns, {}))
185
- for path, v in staged:
186
- _set_path(ns_values, path, v)
187
- if ndef["validate"]:
188
- err = ndef["validate"](ns_values)
189
- if err:
190
- raise ValueError("namespace 校验失败:%s" % err)
191
- _VALUES[ns] = ns_values
192
- _REVISIONS[ns] = _REVISIONS.get(ns, 1) + 1
193
- _persist()
194
- return _REVISIONS[ns]
195
-
196
-
197
- def describe(ns, redact_secrets=True):
198
- """给 UI 的视图:默认把 redact 字段替换为 __REDACTED__(不泄露真值)。
199
-
200
- fields 带回字段元数据(类型/说明/choices/clamp),前端调参卡按 schema
201
- 渲染控件,schema 变更零前端改动。"""
202
- with _LOCK:
203
- vals = json.loads(json.dumps(_VALUES.get(ns, {}))) # deep copy
204
- ndef = _NAMESPACES.get(ns) or {"fields": {}}
205
- fields = [{"path": f.path, "type": f.ftype, "description": f.description,
206
- "choices": f.choices, "clamp": f.clamp}
207
- for f in ndef["fields"].values()]
208
- for f in ndef["fields"].values():
209
- if f.redact and redact_secrets:
210
- if _get_path(vals, f.path) not in (None, ""):
211
- _set_path(vals, f.path, "__REDACTED__")
212
- return {"ns": ns, "revision": _REVISIONS.get(ns, 0), "values": vals,
213
- "fields": fields}
214
-
215
-
216
- # ---- 默认 namespace(编排阈值的 schema 化存放处;pipeline 可渐进接入) ----
217
-
218
- def register_default_namespaces():
219
- """注册 Tutti 默认 namespace。幂等(已注册则跳过)。"""
220
- if "orchestrator" in _NAMESPACES and "budget" in _NAMESPACES:
221
- return
222
- if "orchestrator" not in _NAMESPACES:
223
- register_namespace(
224
- "orchestrator",
225
- fields=[
226
- FieldDef("compaction.enabled", "bool", False,
227
- "上下文压缩总开关(灰度:与 TUTTI_COMPACTION 任一开启即生效)"),
228
- FieldDef("compaction.pressure_threshold", "float", 0.8,
229
- "触发压缩的压力比阈值",
230
- clamp=(0.1, 0.99)),
231
- FieldDef("compaction.retain_tail_tokens", "int", 8000,
232
- "压缩时保留尾部预算(token 估算)",
233
- clamp=(0, 100000)),
234
- FieldDef("max_goal_rounds", "int", 5,
235
- "Goal 续行上限",
236
- clamp=(1, 20)),
237
- FieldDef("outline_timeout_s", "int", 900,
238
- "大纲/规划 CLI 兜底调用的超时秒数(env TUTTI_OUTLINE_TIMEOUT 优先)",
239
- clamp=(60, 3600)),
240
- ],
241
- validate=lambda v: None,
242
- )
243
- if "budget" not in _NAMESPACES:
244
- register_namespace(
245
- "budget",
246
- fields=[
247
- # §07 T2.1:0 = 不限;达到上限后 pipeline 停止后续真实调用(ENV_BLOCK)
248
- FieldDef("max_tokens_per_run", "int", 0,
249
- "单次运行 token 预算上限(0=不限;超额停止后续步骤,等自动续跑)",
250
- clamp=(0, 100_000_000)),
251
- # 花费硬顶(¥,按台账标定单价折算:手标单价优先,回落导入价表;
252
- # 未标定单价的调用不记钱也不计入——宁缺毋滥):0 = 不限。
253
- FieldDef("daily_cost_yuan", "float", 0.0,
254
- "每日花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
255
- clamp=(0, 1_000_000)),
256
- FieldDef("monthly_cost_yuan", "float", 0.0,
257
- "每月花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
258
- clamp=(0, 1_000_000)),
259
- ],
260
- validate=lambda v: None,
261
- )
262
- if "cascade" not in _NAMESPACES:
263
- register_namespace(
264
- "cascade",
265
- fields=[
266
- # §07 T3.1:easy 任务先走低档模型,质量闸门不过沿 chain 升级
267
- FieldDef("enabled", "bool", False,
268
- "FrugalGPT 式级联:easy 任务按 tier 升序重排模型链(需供应商声明 tier)"),
269
- ],
270
- validate=lambda v: None,
1
+ # -*- coding: utf-8 -*-
2
+ """Schema 化设置:namespace 注册 + revision CAS + 脱敏 describe。
3
+
4
+ 设计稿:docs/migration/05-model-seams.md §4C(按 Tutti 实际架构落地:
5
+ 不替换现有 settings.py——它正被并行迭代且已有原子写+钳制;本模块提供
6
+ 「schema 驱动 + revision CAS + secret 脱敏」能力,管理自己的
7
+ data/settings_v2.json,供新增配置项使用,存量 key-value 不迁移)。
8
+
9
+ 参考 dsh packages/settings/settings/src/index.ts:419-637:
10
+ register(ns, schema, base) / mutate(ops) / describe({redactSecrets}) /
11
+ SettingsConflictError(revision 单调,乐观并发)。
12
+
13
+ 冲突语义:mutate 带 expected_revision 时做 CAS;不带则最后写赢。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import threading
20
+ from pathlib import Path
21
+
22
+ _LOCK = threading.RLock()
23
+ _FILE = None # init() 注入
24
+ _NAMESPACES = {} # ns -> {"fields": {path: FieldDef}, "validate": fn}
25
+ _VALUES = {} # ns -> {path: value}
26
+ _REVISIONS = {} # ns -> int
27
+
28
+
29
+ class SettingsConflictError(Exception):
30
+ """CAS 失败:调用方持有的是陈旧 revision。"""
31
+
32
+
33
+ class FieldDef:
34
+ __slots__ = ("path", "ftype", "default", "description", "redact", "choices", "clamp")
35
+
36
+ def __init__(self, path, ftype, default, description="", redact=False,
37
+ choices=None, clamp=None):
38
+ self.path = path
39
+ self.ftype = ftype # "int" | "float" | "str" | "bool"
40
+ self.default = default
41
+ self.description = description
42
+ self.redact = redact # True = describe() 输出 __REDACTED__
43
+ self.choices = choices # 可选:合法值列表
44
+ self.clamp = clamp # 可选:(min, max),int/float 用
45
+
46
+
47
+ def init(data_dir=None):
48
+ global _FILE, _NAMESPACES, _VALUES, _REVISIONS
49
+ with _LOCK:
50
+ base = Path(data_dir) if data_dir else Path(_default_dir())
51
+ base.mkdir(parents=True, exist_ok=True)
52
+ _FILE = base / "settings_v2.json"
53
+ _NAMESPACES, _VALUES, _REVISIONS = {}, {}, {}
54
+ try:
55
+ data = json.loads(_FILE.read_text(encoding="utf-8"))
56
+ _VALUES = data.get("values") or {}
57
+ _REVISIONS = data.get("revisions") or {}
58
+ except (OSError, json.JSONDecodeError):
59
+ pass
60
+
61
+
62
+ def _default_dir():
63
+ from . import paths
64
+ return str(paths.DATA_DIR)
65
+
66
+
67
+ def _persist():
68
+ if _FILE is None:
69
+ return
70
+ tmp = _FILE.with_suffix(".tmp")
71
+ tmp.write_text(json.dumps({"values": _VALUES, "revisions": _REVISIONS},
72
+ ensure_ascii=False, indent=2), encoding="utf-8")
73
+ tmp.replace(_FILE)
74
+
75
+
76
+ # ---- 路径读写(支持 "a.b" 点路径;不实现数组下标——Tutti 配置用不到) ----
77
+
78
+ def _get_path(d, path):
79
+ cur = d
80
+ for seg in path.split("."):
81
+ if not isinstance(cur, dict) or seg not in cur:
82
+ return None
83
+ cur = cur[seg]
84
+ return cur
85
+
86
+
87
+ def _set_path(d, path, value):
88
+ segs = path.split(".")
89
+ cur = d
90
+ for seg in segs[:-1]:
91
+ cur = cur.setdefault(seg, {})
92
+ if not isinstance(cur, dict):
93
+ raise ValueError("路径冲突:%s" % path)
94
+ cur[segs[-1]] = value
95
+
96
+
97
+ # ---- 注册 ----
98
+
99
+ def register_namespace(ns, fields, validate=None):
100
+ """注册 namespace 与字段 schema。已注册的 namespace 不可重复注册。"""
101
+ with _LOCK:
102
+ if ns in _NAMESPACES:
103
+ raise ValueError("namespace 已注册:%s" % ns)
104
+ fmap = {}
105
+ for f in fields:
106
+ fmap[f.path] = f
107
+ if _get_path(_VALUES.get(ns, {}), f.path) is None:
108
+ _VALUES.setdefault(ns, {})
109
+ _set_path(_VALUES[ns], f.path, f.default)
110
+ _NAMESPACES[ns] = {"fields": fmap, "validate": validate}
111
+ _REVISIONS.setdefault(ns, 1)
112
+ _persist()
113
+
114
+
115
+ def coerce(field, value):
116
+ """按字段类型强转 + 钳制 + choices 校验。返回 (规范化值, 错误)。"""
117
+ try:
118
+ if field.ftype == "int":
119
+ v = int(value)
120
+ elif field.ftype == "float":
121
+ v = float(value)
122
+ elif field.ftype == "bool":
123
+ v = bool(value) if not isinstance(value, str) else (
124
+ value.strip().lower() in ("1", "true", "yes", "on"))
125
+ else:
126
+ v = str(value)
127
+ except (TypeError, ValueError):
128
+ return None, "%s 必须是 %s" % (field.path, field.ftype)
129
+ if field.clamp:
130
+ lo, hi = field.clamp
131
+ v = max(lo, min(hi, v))
132
+ if field.choices is not None and v not in field.choices:
133
+ return None, "%s 必须是 %s 之一" % (field.path, field.choices)
134
+ return v, None
135
+
136
+
137
+ # ---- 读写 ----
138
+
139
+ def get(ns, path=None):
140
+ with _LOCK:
141
+ vals = _VALUES.get(ns)
142
+ if vals is None:
143
+ return None
144
+ return _get_path(vals, path) if path else dict(vals)
145
+
146
+
147
+ def revision(ns):
148
+ with _LOCK:
149
+ return _REVISIONS.get(ns, 0)
150
+
151
+
152
+ def names():
153
+ """已注册 namespace 清单(GET /api/settings-v2 用)。"""
154
+ with _LOCK:
155
+ return sorted(_NAMESPACES)
156
+
157
+
158
+ def mutate(ns, ops, expected_revision=None):
159
+ """写一组操作。ops = [{"op":"set","path":..., "value":...}, ...]。
160
+
161
+ 值按注册的 FieldDef 强转/钳制/校验;namespace 有 validate 钩子时整体验证。
162
+ expected_revision 非 None 时做 CAS。成功返回新 revision。
163
+ """
164
+ with _LOCK:
165
+ ndef = _NAMESPACES.get(ns)
166
+ if not ndef:
167
+ raise ValueError("namespace 未注册:%s" % ns)
168
+ if expected_revision is not None and _REVISIONS.get(ns) != expected_revision:
169
+ raise SettingsConflictError(
170
+ "%s 期望 rev %s,实际 %s" % (ns, expected_revision, _REVISIONS.get(ns)))
171
+ # 先全部校验,后统一写入(部分失败不落盘)
172
+ staged = []
173
+ for op in ops:
174
+ if op.get("op") != "set":
175
+ raise ValueError("仅支持 set 操作:%r" % op.get("op"))
176
+ path = op.get("path")
177
+ fdef = ndef["fields"].get(path)
178
+ if not fdef:
179
+ raise ValueError("未注册字段:%s" % path)
180
+ v, err = coerce(fdef, op.get("value"))
181
+ if err:
182
+ raise ValueError(err)
183
+ staged.append((path, v))
184
+ ns_values = dict(_VALUES.get(ns, {}))
185
+ for path, v in staged:
186
+ _set_path(ns_values, path, v)
187
+ if ndef["validate"]:
188
+ err = ndef["validate"](ns_values)
189
+ if err:
190
+ raise ValueError("namespace 校验失败:%s" % err)
191
+ _VALUES[ns] = ns_values
192
+ _REVISIONS[ns] = _REVISIONS.get(ns, 1) + 1
193
+ _persist()
194
+ return _REVISIONS[ns]
195
+
196
+
197
+ def describe(ns, redact_secrets=True):
198
+ """给 UI 的视图:默认把 redact 字段替换为 __REDACTED__(不泄露真值)。
199
+
200
+ fields 带回字段元数据(类型/说明/choices/clamp),前端调参卡按 schema
201
+ 渲染控件,schema 变更零前端改动。"""
202
+ with _LOCK:
203
+ vals = json.loads(json.dumps(_VALUES.get(ns, {}))) # deep copy
204
+ ndef = _NAMESPACES.get(ns) or {"fields": {}}
205
+ fields = [{"path": f.path, "type": f.ftype, "description": f.description,
206
+ "choices": f.choices, "clamp": f.clamp}
207
+ for f in ndef["fields"].values()]
208
+ for f in ndef["fields"].values():
209
+ if f.redact and redact_secrets:
210
+ if _get_path(vals, f.path) not in (None, ""):
211
+ _set_path(vals, f.path, "__REDACTED__")
212
+ return {"ns": ns, "revision": _REVISIONS.get(ns, 0), "values": vals,
213
+ "fields": fields}
214
+
215
+
216
+ # ---- 默认 namespace(编排阈值的 schema 化存放处;pipeline 可渐进接入) ----
217
+
218
+ def register_default_namespaces():
219
+ """注册 Tutti 默认 namespace。幂等(已注册则跳过)。"""
220
+ if "orchestrator" in _NAMESPACES and "budget" in _NAMESPACES:
221
+ return
222
+ if "orchestrator" not in _NAMESPACES:
223
+ register_namespace(
224
+ "orchestrator",
225
+ fields=[
226
+ FieldDef("compaction.enabled", "bool", False,
227
+ "上下文压缩总开关(灰度:与 TUTTI_COMPACTION 任一开启即生效)"),
228
+ FieldDef("compaction.pressure_threshold", "float", 0.8,
229
+ "触发压缩的压力比阈值",
230
+ clamp=(0.1, 0.99)),
231
+ FieldDef("compaction.retain_tail_tokens", "int", 8000,
232
+ "压缩时保留尾部预算(token 估算)",
233
+ clamp=(0, 100000)),
234
+ FieldDef("max_goal_rounds", "int", 5,
235
+ "Goal 续行上限",
236
+ clamp=(1, 20)),
237
+ FieldDef("outline_timeout_s", "int", 900,
238
+ "大纲/规划 CLI 兜底调用的超时秒数(env TUTTI_OUTLINE_TIMEOUT 优先)",
239
+ clamp=(60, 3600)),
240
+ ],
241
+ validate=lambda v: None,
242
+ )
243
+ if "budget" not in _NAMESPACES:
244
+ register_namespace(
245
+ "budget",
246
+ fields=[
247
+ # §07 T2.1:0 = 不限;达到上限后 pipeline 停止后续真实调用(ENV_BLOCK)
248
+ FieldDef("max_tokens_per_run", "int", 0,
249
+ "单次运行 token 预算上限(0=不限;超额停止后续步骤,等自动续跑)",
250
+ clamp=(0, 100_000_000)),
251
+ # 花费硬顶(¥,按台账标定单价折算:手标单价优先,回落导入价表;
252
+ # 未标定单价的调用不记钱也不计入——宁缺毋滥):0 = 不限。
253
+ FieldDef("daily_cost_yuan", "float", 0.0,
254
+ "每日花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
255
+ clamp=(0, 1_000_000)),
256
+ FieldDef("monthly_cost_yuan", "float", 0.0,
257
+ "每月花费上限(¥,按标定单价折算;0=不限;超额停止后续步骤)",
258
+ clamp=(0, 1_000_000)),
259
+ ],
260
+ validate=lambda v: None,
261
+ )
262
+ if "cascade" not in _NAMESPACES:
263
+ register_namespace(
264
+ "cascade",
265
+ fields=[
266
+ # §07 T3.1:easy 任务先走低档模型,质量闸门不过沿 chain 升级
267
+ FieldDef("enabled", "bool", False,
268
+ "FrugalGPT 式级联:easy 任务按 tier 升序重排模型链(需供应商声明 tier)"),
269
+ ],
270
+ validate=lambda v: None,
271
271
  )