codebee 0.1.95 → 0.1.96
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 +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- 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 +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- 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/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- 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 +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- 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 +0 -210
- 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/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/contracts.py
CHANGED
|
@@ -1,255 +1,255 @@
|
|
|
1
|
-
"""Durable task contracts, evidence, approvals and completion receipts."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
|
-
import threading
|
|
7
|
-
import time
|
|
8
|
-
import uuid
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from . import paths
|
|
12
|
-
|
|
13
|
-
_DIR = paths.DATA_DIR / "contracts"
|
|
14
|
-
_LOCK = threading.RLock()
|
|
15
|
-
_ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
|
|
16
|
-
_CRITERIA_MAX = 40
|
|
17
|
-
_EVIDENCE_MAX = 300
|
|
18
|
-
_MISSING = object()
|
|
19
|
-
_CASE_KEYWORDS = {
|
|
20
|
-
"correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
|
|
21
|
-
"performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
|
|
22
|
-
"boundary": ("边界", "极端", "boundary", "edge"),
|
|
23
|
-
"exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
|
|
24
|
-
"readability": ("可读", "文档", "表达", "readability", "documentation"),
|
|
25
|
-
"security": ("安全", "权限", "注入", "security", "auth", "injection"),
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _valid_id(task_id):
|
|
30
|
-
return bool(_ID_RE.fullmatch(str(task_id or "")))
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _path(task_id):
|
|
34
|
-
if not _valid_id(task_id):
|
|
35
|
-
raise ValueError("任务 ID 无效")
|
|
36
|
-
return _DIR / (str(task_id) + ".json")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _now():
|
|
40
|
-
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _save(item):
|
|
44
|
-
p = _path(item["task_id"])
|
|
45
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
-
tmp = p.with_suffix(".tmp")
|
|
47
|
-
tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
48
|
-
tmp.replace(p)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _load(task_id):
|
|
52
|
-
try:
|
|
53
|
-
value = json.loads(_path(task_id).read_text(encoding="utf-8"))
|
|
54
|
-
return value if isinstance(value, dict) else None
|
|
55
|
-
except (OSError, ValueError):
|
|
56
|
-
return None
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _actor(value):
|
|
60
|
-
return str(value or "local").strip()[:120] or "local"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def create(task_id, payload=None):
|
|
64
|
-
"""Create or update a task's contract, preserving evidence and audit history."""
|
|
65
|
-
payload = payload if isinstance(payload, dict) else {}
|
|
66
|
-
raw_criteria = payload.get("acceptance_criteria", _MISSING)
|
|
67
|
-
if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
|
|
68
|
-
raise ValueError("acceptance_criteria 必须是列表")
|
|
69
|
-
clean = []
|
|
70
|
-
for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
|
|
71
|
-
text = str(value or "").strip()[:500]
|
|
72
|
-
if text and text not in clean:
|
|
73
|
-
clean.append(text)
|
|
74
|
-
with _LOCK:
|
|
75
|
-
item = _load(task_id) or {
|
|
76
|
-
"version": 1, "task_id": str(task_id), "created_at": _now(),
|
|
77
|
-
"acceptance_criteria": [], "evidence": [], "approval_required": False,
|
|
78
|
-
"approval": None, "blocked_reason": "", "completion_receipt": None,
|
|
79
|
-
"audit": [], "status": "open",
|
|
80
|
-
}
|
|
81
|
-
# Partial updates must not erase an existing contract field merely
|
|
82
|
-
# because the caller omitted it (MCP/UI frequently send sparse patches).
|
|
83
|
-
if raw_criteria is not _MISSING:
|
|
84
|
-
item["acceptance_criteria"] = clean
|
|
85
|
-
if "approval_required" in payload:
|
|
86
|
-
approval_required = payload.get("approval_required")
|
|
87
|
-
if not isinstance(approval_required, bool):
|
|
88
|
-
raise ValueError("approval_required 必须是布尔值")
|
|
89
|
-
item["approval_required"] = approval_required
|
|
90
|
-
item["updated_at"] = _now()
|
|
91
|
-
item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
|
|
92
|
-
"action": "contract_updated",
|
|
93
|
-
"criteria_count": len(item.get("acceptance_criteria") or [])})
|
|
94
|
-
_save(item)
|
|
95
|
-
return item
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def get(task_id):
|
|
99
|
-
with _LOCK:
|
|
100
|
-
return _load(task_id)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def add_evidence(task_id, evidence, actor="local"):
|
|
104
|
-
if not isinstance(evidence, dict):
|
|
105
|
-
raise ValueError("证据必须是对象")
|
|
106
|
-
status = str(evidence.get("status") or "unknown").lower()
|
|
107
|
-
if status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
108
|
-
raise ValueError("证据状态无效")
|
|
109
|
-
with _LOCK:
|
|
110
|
-
item = _load(task_id)
|
|
111
|
-
if not item:
|
|
112
|
-
item = create(task_id, {})
|
|
113
|
-
row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
|
|
114
|
-
"criterion": str(evidence.get("criterion") or "")[:500], "status": status,
|
|
115
|
-
"summary": str(evidence.get("summary") or "")[:2000],
|
|
116
|
-
"source": str(evidence.get("source") or "manual")[:80], "at": _now(),
|
|
117
|
-
"actor": _actor(actor)}
|
|
118
|
-
item["evidence"].append(row)
|
|
119
|
-
item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
|
|
120
|
-
item["audit"].append({"at": row["at"], "actor": row["actor"],
|
|
121
|
-
"action": "evidence_added", "evidence_id": row["id"]})
|
|
122
|
-
item["updated_at"] = row["at"]
|
|
123
|
-
_save(item)
|
|
124
|
-
return item
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def record_acceptance_cases(task_id, cases, actor="system"):
|
|
128
|
-
"""Map acceptance-matrix cases onto matching user criteria.
|
|
129
|
-
|
|
130
|
-
Matching is deliberately conservative: only criteria containing a known
|
|
131
|
-
case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
|
|
132
|
-
unmatched criterion remains unverified rather than being guessed green.
|
|
133
|
-
"""
|
|
134
|
-
if not isinstance(cases, list):
|
|
135
|
-
return get(task_id)
|
|
136
|
-
item = get(task_id)
|
|
137
|
-
if not item:
|
|
138
|
-
return None
|
|
139
|
-
criteria = item.get("acceptance_criteria") or []
|
|
140
|
-
for case in cases:
|
|
141
|
-
if not isinstance(case, dict):
|
|
142
|
-
continue
|
|
143
|
-
name = str(case.get("case") or "").strip().lower()
|
|
144
|
-
status = str(case.get("status") or "unknown").lower()
|
|
145
|
-
if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
146
|
-
continue
|
|
147
|
-
for criterion in criteria:
|
|
148
|
-
text = str(criterion or "").strip()
|
|
149
|
-
low = text.lower()
|
|
150
|
-
explicit = low == "acceptance:" + name
|
|
151
|
-
matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
|
|
152
|
-
if not matched:
|
|
153
|
-
continue
|
|
154
|
-
add_evidence(task_id, {"criterion": text, "status": status,
|
|
155
|
-
"summary": case.get("reason") or
|
|
156
|
-
"acceptance matrix: " + name,
|
|
157
|
-
"source": "acceptance"}, actor=actor)
|
|
158
|
-
return get(task_id)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _decision(task_id, status, actor, note):
|
|
162
|
-
with _LOCK:
|
|
163
|
-
item = _load(task_id)
|
|
164
|
-
if not item:
|
|
165
|
-
return None, "任务契约不存在"
|
|
166
|
-
decision = {"status": status, "actor": _actor(actor),
|
|
167
|
-
"note": str(note or "").strip()[:1000], "at": _now()}
|
|
168
|
-
item["approval"] = decision
|
|
169
|
-
item["status"] = "approved" if status == "approved" else "open"
|
|
170
|
-
item["audit"].append(dict(decision, action=status))
|
|
171
|
-
item["updated_at"] = decision["at"]
|
|
172
|
-
_save(item)
|
|
173
|
-
return item, None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def approve(task_id, actor="local", note=""):
|
|
177
|
-
return _decision(task_id, "approved", actor, note)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def reject(task_id, actor="local", note=""):
|
|
181
|
-
if not str(note or "").strip():
|
|
182
|
-
return None, "拒绝审批必须说明原因"
|
|
183
|
-
return _decision(task_id, "rejected", actor, note)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def set_blocked(task_id, reason, actor="local"):
|
|
187
|
-
reason = str(reason or "").strip()[:1000]
|
|
188
|
-
if not reason:
|
|
189
|
-
return None, "Blocked 状态必须填写原因"
|
|
190
|
-
with _LOCK:
|
|
191
|
-
item = _load(task_id)
|
|
192
|
-
if not item:
|
|
193
|
-
return None, "任务契约不存在"
|
|
194
|
-
item["status"] = "blocked"
|
|
195
|
-
item["blocked_reason"] = reason
|
|
196
|
-
item["updated_at"] = _now()
|
|
197
|
-
item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
|
|
198
|
-
"action": "blocked", "reason": reason})
|
|
199
|
-
_save(item)
|
|
200
|
-
return item, None
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def release_allowed(task_id):
|
|
204
|
-
item = get(task_id)
|
|
205
|
-
# Task creation persists the approval bit redundantly for backwards
|
|
206
|
-
# compatibility. Combine both sources with OR so a damaged/tampered
|
|
207
|
-
# sidecar cannot downgrade an approval-protected task to releasable.
|
|
208
|
-
try:
|
|
209
|
-
from . import store
|
|
210
|
-
task = store.get_task(task_id) or {}
|
|
211
|
-
except Exception:
|
|
212
|
-
task = {}
|
|
213
|
-
requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
|
|
214
|
-
if not requires:
|
|
215
|
-
return True
|
|
216
|
-
if item is None:
|
|
217
|
-
return False
|
|
218
|
-
approval = item.get("approval") or {}
|
|
219
|
-
return approval.get("status") == "approved"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def complete(task_id, run):
|
|
223
|
-
if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
|
|
224
|
-
return None, "完成回执只能关联终态运行"
|
|
225
|
-
with _LOCK:
|
|
226
|
-
item = _load(task_id)
|
|
227
|
-
if not item:
|
|
228
|
-
item = create(task_id, {})
|
|
229
|
-
criteria = item.get("acceptance_criteria") or []
|
|
230
|
-
evidence = item.get("evidence") or []
|
|
231
|
-
latest = {}
|
|
232
|
-
for row in evidence:
|
|
233
|
-
if row.get("criterion"):
|
|
234
|
-
latest[row["criterion"]] = row
|
|
235
|
-
checked = [latest.get(c) for c in criteria]
|
|
236
|
-
receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
|
|
237
|
-
"run_status": run.get("status"), "criteria_total": len(criteria),
|
|
238
|
-
"criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
|
|
239
|
-
"criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
|
|
240
|
-
"criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
|
|
241
|
-
"evidence_ids": [row.get("id") for row in checked if row],
|
|
242
|
-
"cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
|
|
243
|
-
"completed_at": _now()}
|
|
244
|
-
# Completion is idempotent for the same run. A late duplicate status
|
|
245
|
-
# write must not manufacture a second audit event or mutate the
|
|
246
|
-
# original cost/token receipt.
|
|
247
|
-
previous = item.get("completion_receipt") or {}
|
|
248
|
-
if previous.get("run_id") == receipt["run_id"]:
|
|
249
|
-
return previous, None
|
|
250
|
-
item["completion_receipt"] = receipt
|
|
251
|
-
item["audit"].append({"at": receipt["completed_at"], "actor": "system",
|
|
252
|
-
"action": "completion_receipt_created", "run_id": run.get("id")})
|
|
253
|
-
item["updated_at"] = receipt["completed_at"]
|
|
254
|
-
_save(item)
|
|
255
|
-
return receipt, None
|
|
1
|
+
"""Durable task contracts, evidence, approvals and completion receipts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import paths
|
|
12
|
+
|
|
13
|
+
_DIR = paths.DATA_DIR / "contracts"
|
|
14
|
+
_LOCK = threading.RLock()
|
|
15
|
+
_ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
|
|
16
|
+
_CRITERIA_MAX = 40
|
|
17
|
+
_EVIDENCE_MAX = 300
|
|
18
|
+
_MISSING = object()
|
|
19
|
+
_CASE_KEYWORDS = {
|
|
20
|
+
"correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
|
|
21
|
+
"performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
|
|
22
|
+
"boundary": ("边界", "极端", "boundary", "edge"),
|
|
23
|
+
"exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
|
|
24
|
+
"readability": ("可读", "文档", "表达", "readability", "documentation"),
|
|
25
|
+
"security": ("安全", "权限", "注入", "security", "auth", "injection"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _valid_id(task_id):
|
|
30
|
+
return bool(_ID_RE.fullmatch(str(task_id or "")))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _path(task_id):
|
|
34
|
+
if not _valid_id(task_id):
|
|
35
|
+
raise ValueError("任务 ID 无效")
|
|
36
|
+
return _DIR / (str(task_id) + ".json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now():
|
|
40
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save(item):
|
|
44
|
+
p = _path(item["task_id"])
|
|
45
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = p.with_suffix(".tmp")
|
|
47
|
+
tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
48
|
+
tmp.replace(p)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load(task_id):
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(_path(task_id).read_text(encoding="utf-8"))
|
|
54
|
+
return value if isinstance(value, dict) else None
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _actor(value):
|
|
60
|
+
return str(value or "local").strip()[:120] or "local"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create(task_id, payload=None):
|
|
64
|
+
"""Create or update a task's contract, preserving evidence and audit history."""
|
|
65
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
66
|
+
raw_criteria = payload.get("acceptance_criteria", _MISSING)
|
|
67
|
+
if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
|
|
68
|
+
raise ValueError("acceptance_criteria 必须是列表")
|
|
69
|
+
clean = []
|
|
70
|
+
for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
|
|
71
|
+
text = str(value or "").strip()[:500]
|
|
72
|
+
if text and text not in clean:
|
|
73
|
+
clean.append(text)
|
|
74
|
+
with _LOCK:
|
|
75
|
+
item = _load(task_id) or {
|
|
76
|
+
"version": 1, "task_id": str(task_id), "created_at": _now(),
|
|
77
|
+
"acceptance_criteria": [], "evidence": [], "approval_required": False,
|
|
78
|
+
"approval": None, "blocked_reason": "", "completion_receipt": None,
|
|
79
|
+
"audit": [], "status": "open",
|
|
80
|
+
}
|
|
81
|
+
# Partial updates must not erase an existing contract field merely
|
|
82
|
+
# because the caller omitted it (MCP/UI frequently send sparse patches).
|
|
83
|
+
if raw_criteria is not _MISSING:
|
|
84
|
+
item["acceptance_criteria"] = clean
|
|
85
|
+
if "approval_required" in payload:
|
|
86
|
+
approval_required = payload.get("approval_required")
|
|
87
|
+
if not isinstance(approval_required, bool):
|
|
88
|
+
raise ValueError("approval_required 必须是布尔值")
|
|
89
|
+
item["approval_required"] = approval_required
|
|
90
|
+
item["updated_at"] = _now()
|
|
91
|
+
item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
|
|
92
|
+
"action": "contract_updated",
|
|
93
|
+
"criteria_count": len(item.get("acceptance_criteria") or [])})
|
|
94
|
+
_save(item)
|
|
95
|
+
return item
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get(task_id):
|
|
99
|
+
with _LOCK:
|
|
100
|
+
return _load(task_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def add_evidence(task_id, evidence, actor="local"):
|
|
104
|
+
if not isinstance(evidence, dict):
|
|
105
|
+
raise ValueError("证据必须是对象")
|
|
106
|
+
status = str(evidence.get("status") or "unknown").lower()
|
|
107
|
+
if status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
108
|
+
raise ValueError("证据状态无效")
|
|
109
|
+
with _LOCK:
|
|
110
|
+
item = _load(task_id)
|
|
111
|
+
if not item:
|
|
112
|
+
item = create(task_id, {})
|
|
113
|
+
row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
|
|
114
|
+
"criterion": str(evidence.get("criterion") or "")[:500], "status": status,
|
|
115
|
+
"summary": str(evidence.get("summary") or "")[:2000],
|
|
116
|
+
"source": str(evidence.get("source") or "manual")[:80], "at": _now(),
|
|
117
|
+
"actor": _actor(actor)}
|
|
118
|
+
item["evidence"].append(row)
|
|
119
|
+
item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
|
|
120
|
+
item["audit"].append({"at": row["at"], "actor": row["actor"],
|
|
121
|
+
"action": "evidence_added", "evidence_id": row["id"]})
|
|
122
|
+
item["updated_at"] = row["at"]
|
|
123
|
+
_save(item)
|
|
124
|
+
return item
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record_acceptance_cases(task_id, cases, actor="system"):
|
|
128
|
+
"""Map acceptance-matrix cases onto matching user criteria.
|
|
129
|
+
|
|
130
|
+
Matching is deliberately conservative: only criteria containing a known
|
|
131
|
+
case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
|
|
132
|
+
unmatched criterion remains unverified rather than being guessed green.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(cases, list):
|
|
135
|
+
return get(task_id)
|
|
136
|
+
item = get(task_id)
|
|
137
|
+
if not item:
|
|
138
|
+
return None
|
|
139
|
+
criteria = item.get("acceptance_criteria") or []
|
|
140
|
+
for case in cases:
|
|
141
|
+
if not isinstance(case, dict):
|
|
142
|
+
continue
|
|
143
|
+
name = str(case.get("case") or "").strip().lower()
|
|
144
|
+
status = str(case.get("status") or "unknown").lower()
|
|
145
|
+
if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
146
|
+
continue
|
|
147
|
+
for criterion in criteria:
|
|
148
|
+
text = str(criterion or "").strip()
|
|
149
|
+
low = text.lower()
|
|
150
|
+
explicit = low == "acceptance:" + name
|
|
151
|
+
matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
|
|
152
|
+
if not matched:
|
|
153
|
+
continue
|
|
154
|
+
add_evidence(task_id, {"criterion": text, "status": status,
|
|
155
|
+
"summary": case.get("reason") or
|
|
156
|
+
"acceptance matrix: " + name,
|
|
157
|
+
"source": "acceptance"}, actor=actor)
|
|
158
|
+
return get(task_id)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _decision(task_id, status, actor, note):
|
|
162
|
+
with _LOCK:
|
|
163
|
+
item = _load(task_id)
|
|
164
|
+
if not item:
|
|
165
|
+
return None, "任务契约不存在"
|
|
166
|
+
decision = {"status": status, "actor": _actor(actor),
|
|
167
|
+
"note": str(note or "").strip()[:1000], "at": _now()}
|
|
168
|
+
item["approval"] = decision
|
|
169
|
+
item["status"] = "approved" if status == "approved" else "open"
|
|
170
|
+
item["audit"].append(dict(decision, action=status))
|
|
171
|
+
item["updated_at"] = decision["at"]
|
|
172
|
+
_save(item)
|
|
173
|
+
return item, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def approve(task_id, actor="local", note=""):
|
|
177
|
+
return _decision(task_id, "approved", actor, note)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def reject(task_id, actor="local", note=""):
|
|
181
|
+
if not str(note or "").strip():
|
|
182
|
+
return None, "拒绝审批必须说明原因"
|
|
183
|
+
return _decision(task_id, "rejected", actor, note)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_blocked(task_id, reason, actor="local"):
|
|
187
|
+
reason = str(reason or "").strip()[:1000]
|
|
188
|
+
if not reason:
|
|
189
|
+
return None, "Blocked 状态必须填写原因"
|
|
190
|
+
with _LOCK:
|
|
191
|
+
item = _load(task_id)
|
|
192
|
+
if not item:
|
|
193
|
+
return None, "任务契约不存在"
|
|
194
|
+
item["status"] = "blocked"
|
|
195
|
+
item["blocked_reason"] = reason
|
|
196
|
+
item["updated_at"] = _now()
|
|
197
|
+
item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
|
|
198
|
+
"action": "blocked", "reason": reason})
|
|
199
|
+
_save(item)
|
|
200
|
+
return item, None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def release_allowed(task_id):
|
|
204
|
+
item = get(task_id)
|
|
205
|
+
# Task creation persists the approval bit redundantly for backwards
|
|
206
|
+
# compatibility. Combine both sources with OR so a damaged/tampered
|
|
207
|
+
# sidecar cannot downgrade an approval-protected task to releasable.
|
|
208
|
+
try:
|
|
209
|
+
from . import store
|
|
210
|
+
task = store.get_task(task_id) or {}
|
|
211
|
+
except Exception:
|
|
212
|
+
task = {}
|
|
213
|
+
requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
|
|
214
|
+
if not requires:
|
|
215
|
+
return True
|
|
216
|
+
if item is None:
|
|
217
|
+
return False
|
|
218
|
+
approval = item.get("approval") or {}
|
|
219
|
+
return approval.get("status") == "approved"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def complete(task_id, run):
|
|
223
|
+
if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
|
|
224
|
+
return None, "完成回执只能关联终态运行"
|
|
225
|
+
with _LOCK:
|
|
226
|
+
item = _load(task_id)
|
|
227
|
+
if not item:
|
|
228
|
+
item = create(task_id, {})
|
|
229
|
+
criteria = item.get("acceptance_criteria") or []
|
|
230
|
+
evidence = item.get("evidence") or []
|
|
231
|
+
latest = {}
|
|
232
|
+
for row in evidence:
|
|
233
|
+
if row.get("criterion"):
|
|
234
|
+
latest[row["criterion"]] = row
|
|
235
|
+
checked = [latest.get(c) for c in criteria]
|
|
236
|
+
receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
|
|
237
|
+
"run_status": run.get("status"), "criteria_total": len(criteria),
|
|
238
|
+
"criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
|
|
239
|
+
"criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
|
|
240
|
+
"criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
|
|
241
|
+
"evidence_ids": [row.get("id") for row in checked if row],
|
|
242
|
+
"cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
|
|
243
|
+
"completed_at": _now()}
|
|
244
|
+
# Completion is idempotent for the same run. A late duplicate status
|
|
245
|
+
# write must not manufacture a second audit event or mutate the
|
|
246
|
+
# original cost/token receipt.
|
|
247
|
+
previous = item.get("completion_receipt") or {}
|
|
248
|
+
if previous.get("run_id") == receipt["run_id"]:
|
|
249
|
+
return previous, None
|
|
250
|
+
item["completion_receipt"] = receipt
|
|
251
|
+
item["audit"].append({"at": receipt["completed_at"], "actor": "system",
|
|
252
|
+
"action": "completion_receipt_created", "run_id": run.get("id")})
|
|
253
|
+
item["updated_at"] = receipt["completed_at"]
|
|
254
|
+
_save(item)
|
|
255
|
+
return receipt, None
|