codebee 0.1.95 → 0.1.97
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 +733 -720
- package/LICENSE +21 -21
- package/README.md +516 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +118 -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 +246 -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 +2055 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +648 -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/mcp_client.py +200 -29
- 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 +319 -59
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +84 -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/project_memory.py +139 -0
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +106 -22
- package/app/core/publish/browser.py +694 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +212 -165
- package/app/core/publish/flow.py +832 -640
- package/app/core/publish/flows-fanqie-calibrated.json +361 -336
- package/app/core/publish/ledger.py +51 -2
- 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 +2239 -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 +33 -5
- package/app/core/settings_schema.py +282 -271
- 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 +24 -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/tool_outputs.py +107 -0
- 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 +865 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +179 -10
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +563 -21
- 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/assets/workbench-reference.png +0 -0
- package/app/ui/board.css +451 -666
- package/app/ui/board.html +165 -162
- package/app/ui/board.js +509 -305
- package/app/ui/hive3d.js +242 -0
- package/app/ui/i18n.js +125 -9
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +60 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +189 -9
- package/bin/tutti.js +147 -147
- package/package.json +49 -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/operations.py
CHANGED
|
@@ -1,228 +1,228 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""Append-only external operation ledger.
|
|
3
|
-
|
|
4
|
-
External writes cannot promise exactly-once delivery. Each write therefore
|
|
5
|
-
has a stable operation id and an explicit state: pending, confirmed, failed or
|
|
6
|
-
unknown. Unknown means the caller must reconcile with the remote system before
|
|
7
|
-
retrying; it is never treated as success.
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import hashlib
|
|
12
|
-
import json
|
|
13
|
-
import secrets
|
|
14
|
-
import threading
|
|
15
|
-
import time
|
|
16
|
-
import urllib.error
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
from . import paths
|
|
20
|
-
|
|
21
|
-
LOCK = threading.RLock()
|
|
22
|
-
STATUSES = ("pending", "confirmed", "failed", "unknown")
|
|
23
|
-
PENDING_TTL_SECONDS = 15 * 60
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _file(day=None) -> Path:
|
|
27
|
-
day = day or time.strftime("%Y-%m-%d")
|
|
28
|
-
return paths.DATA_DIR / "operations" / ("operations-%s.jsonl" % day[:7].replace("-", ""))
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _read_all():
|
|
32
|
-
rows = []
|
|
33
|
-
root = paths.DATA_DIR / "operations"
|
|
34
|
-
try:
|
|
35
|
-
files = sorted(root.glob("operations-*.jsonl"))
|
|
36
|
-
except OSError:
|
|
37
|
-
files = []
|
|
38
|
-
for fp in files:
|
|
39
|
-
try:
|
|
40
|
-
for line in fp.read_text(encoding="utf-8").splitlines():
|
|
41
|
-
try:
|
|
42
|
-
row = json.loads(line)
|
|
43
|
-
except (TypeError, ValueError):
|
|
44
|
-
continue
|
|
45
|
-
if isinstance(row, dict) and row.get("operation_id"):
|
|
46
|
-
rows.append(row)
|
|
47
|
-
except OSError:
|
|
48
|
-
continue
|
|
49
|
-
return rows
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _write_all(rows):
|
|
53
|
-
"""Test/support migration helper; production writes use append-only _append."""
|
|
54
|
-
root = paths.DATA_DIR / "operations"
|
|
55
|
-
root.mkdir(parents=True, exist_ok=True)
|
|
56
|
-
fp = _file()
|
|
57
|
-
fp.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows),
|
|
58
|
-
encoding="utf-8")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _append(row):
|
|
62
|
-
try:
|
|
63
|
-
fp = _file(row.get("submitted_at") and
|
|
64
|
-
time.strftime("%Y-%m-%d", time.localtime(float(row["submitted_at"]))))
|
|
65
|
-
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
-
with fp.open("a", encoding="utf-8") as handle:
|
|
67
|
-
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
|
68
|
-
handle.flush()
|
|
69
|
-
except Exception:
|
|
70
|
-
# Observability must not turn a remote write into a local failure.
|
|
71
|
-
pass
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _request_hash(request) -> str:
|
|
75
|
-
raw = json.dumps(request or {}, ensure_ascii=False, sort_keys=True,
|
|
76
|
-
separators=(",", ":"), default=str)
|
|
77
|
-
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _operation_id(target: str) -> str:
|
|
81
|
-
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
82
|
-
return "op-%s-%s-%s" % (stamp, secrets.token_hex(4),
|
|
83
|
-
hashlib.sha256(str(target).encode()).hexdigest()[:6])
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _safe_meta(metadata):
|
|
87
|
-
if not isinstance(metadata, dict):
|
|
88
|
-
return {}
|
|
89
|
-
out = {}
|
|
90
|
-
for key, value in metadata.items():
|
|
91
|
-
name = str(key)[:40]
|
|
92
|
-
if any(token in name.lower() for token in ("key", "token", "password", "secret")):
|
|
93
|
-
continue
|
|
94
|
-
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
95
|
-
out[name] = str(value)[:300] if isinstance(value, str) else value
|
|
96
|
-
return out
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def begin(target, request=None, *, task_id="", run_id="", metadata=None):
|
|
100
|
-
operation_id = _operation_id(target)
|
|
101
|
-
now = time.time()
|
|
102
|
-
row = {
|
|
103
|
-
"operation_id": operation_id,
|
|
104
|
-
"target": str(target or "")[:160],
|
|
105
|
-
"request_hash": _request_hash(request),
|
|
106
|
-
"submitted_at": now,
|
|
107
|
-
"status": "pending",
|
|
108
|
-
"remote_receipt": "",
|
|
109
|
-
"error": "",
|
|
110
|
-
"last_reconciled_at": None,
|
|
111
|
-
"task_id": str(task_id or "")[:80],
|
|
112
|
-
"run_id": str(run_id or "")[:80],
|
|
113
|
-
"metadata": _safe_meta(metadata),
|
|
114
|
-
}
|
|
115
|
-
with LOCK:
|
|
116
|
-
_append(row)
|
|
117
|
-
return operation_id
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def update(operation_id, status, *, remote_receipt="", error="",
|
|
121
|
-
metadata=None, reconciled=False):
|
|
122
|
-
operation_id = str(operation_id or "").strip()
|
|
123
|
-
status = str(status or "").strip().lower()
|
|
124
|
-
if not operation_id or status not in STATUSES:
|
|
125
|
-
return False
|
|
126
|
-
with LOCK:
|
|
127
|
-
# Read and transition-check under the same lock as append. Two
|
|
128
|
-
# concurrent callbacks must not race a pending operation into
|
|
129
|
-
# conflicting terminal states.
|
|
130
|
-
current = get(operation_id)
|
|
131
|
-
if current is None:
|
|
132
|
-
return False
|
|
133
|
-
current_status = str(current.get("status") or "pending")
|
|
134
|
-
# External effects are monotonic: a confirmed write must never be
|
|
135
|
-
# downgraded by cleanup/error handling. Unknown/failed can only be
|
|
136
|
-
# promoted by an explicit remote reconciliation.
|
|
137
|
-
if current_status == "confirmed" and status != "confirmed":
|
|
138
|
-
return False
|
|
139
|
-
if current_status in ("unknown", "failed") and status == "confirmed" and not reconciled:
|
|
140
|
-
return False
|
|
141
|
-
if current_status in ("unknown", "failed") and status != current_status and not reconciled:
|
|
142
|
-
return False
|
|
143
|
-
if status == "confirmed" and not remote_receipt:
|
|
144
|
-
remote_receipt = current.get("remote_receipt") or ""
|
|
145
|
-
now = time.time()
|
|
146
|
-
row = {
|
|
147
|
-
"operation_id": operation_id,
|
|
148
|
-
"status": status,
|
|
149
|
-
"remote_receipt": str(remote_receipt or "")[:300],
|
|
150
|
-
"error": str(error or "")[:500],
|
|
151
|
-
"last_reconciled_at": now if reconciled else None,
|
|
152
|
-
"updated_at": now,
|
|
153
|
-
}
|
|
154
|
-
if metadata:
|
|
155
|
-
row["metadata"] = _safe_meta(metadata)
|
|
156
|
-
_append(row)
|
|
157
|
-
return True
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def confirm(operation_id, remote_receipt="", *, metadata=None):
|
|
161
|
-
return update(operation_id, "confirmed", remote_receipt=remote_receipt,
|
|
162
|
-
metadata=metadata)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def fail(operation_id, error="", *, metadata=None):
|
|
166
|
-
return update(operation_id, "failed", error=error, metadata=metadata)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def mark_unknown(operation_id, error="", *, metadata=None):
|
|
170
|
-
return update(operation_id, "unknown", error=error, metadata=metadata)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def reconcile(operation_id, status, remote_receipt="", error="", metadata=None):
|
|
174
|
-
"""Record a remote lookup result for an unknown operation."""
|
|
175
|
-
return update(operation_id, status, remote_receipt=remote_receipt, error=error,
|
|
176
|
-
metadata=metadata, reconciled=True)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def get(operation_id):
|
|
180
|
-
operation_id = str(operation_id or "")
|
|
181
|
-
latest = None
|
|
182
|
-
with LOCK:
|
|
183
|
-
for row in _read_all():
|
|
184
|
-
if row.get("operation_id") == operation_id:
|
|
185
|
-
latest = dict(latest or {}, **row)
|
|
186
|
-
return latest
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def recent(*, target="", status="", limit=100):
|
|
190
|
-
latest = {}
|
|
191
|
-
with LOCK:
|
|
192
|
-
for row in _read_all():
|
|
193
|
-
if target and str(row.get("target") or "") != str(target):
|
|
194
|
-
continue
|
|
195
|
-
latest[row["operation_id"]] = dict(latest.get(row["operation_id"]) or {}, **row)
|
|
196
|
-
rows = list(latest.values())
|
|
197
|
-
if status:
|
|
198
|
-
rows = [r for r in rows if r.get("status") == status]
|
|
199
|
-
rows.sort(key=lambda r: float(r.get("updated_at") or r.get("submitted_at") or 0), reverse=True)
|
|
200
|
-
return rows[:max(1, min(1000, int(limit or 100)))]
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def recover_pending(*, now=None, ttl_seconds=PENDING_TTL_SECONDS):
|
|
204
|
-
"""Turn stale pending writes into unknown so restart never replays blindly."""
|
|
205
|
-
now = time.time() if now is None else float(now)
|
|
206
|
-
changed = 0
|
|
207
|
-
for row in recent(status="pending", limit=1000):
|
|
208
|
-
submitted = float(row.get("submitted_at") or 0)
|
|
209
|
-
if submitted and now - submitted > max(1, float(ttl_seconds)):
|
|
210
|
-
if mark_unknown(row["operation_id"], "进程重启后未收到远端回执,请先对账"):
|
|
211
|
-
changed += 1
|
|
212
|
-
return changed
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def status_for_exception(error) -> str:
|
|
216
|
-
text = str(error or "").lower()
|
|
217
|
-
if isinstance(error, (TimeoutError, ConnectionError, urllib.error.URLError)) or any(x in text for x in
|
|
218
|
-
("timeout", "timed out", "超时", "connection", "connect", "connection reset",
|
|
219
|
-
"断开", "断连", "连不上", "网络", "network", "unreachable", "disconnect")):
|
|
220
|
-
return "unknown"
|
|
221
|
-
return "failed"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def finish_exception(operation_id, error):
|
|
225
|
-
status = status_for_exception(error)
|
|
226
|
-
if status == "unknown":
|
|
227
|
-
return mark_unknown(operation_id, str(error))
|
|
228
|
-
return fail(operation_id, str(error))
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Append-only external operation ledger.
|
|
3
|
+
|
|
4
|
+
External writes cannot promise exactly-once delivery. Each write therefore
|
|
5
|
+
has a stable operation id and an explicit state: pending, confirmed, failed or
|
|
6
|
+
unknown. Unknown means the caller must reconcile with the remote system before
|
|
7
|
+
retrying; it is never treated as success.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import paths
|
|
20
|
+
|
|
21
|
+
LOCK = threading.RLock()
|
|
22
|
+
STATUSES = ("pending", "confirmed", "failed", "unknown")
|
|
23
|
+
PENDING_TTL_SECONDS = 15 * 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _file(day=None) -> Path:
|
|
27
|
+
day = day or time.strftime("%Y-%m-%d")
|
|
28
|
+
return paths.DATA_DIR / "operations" / ("operations-%s.jsonl" % day[:7].replace("-", ""))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_all():
|
|
32
|
+
rows = []
|
|
33
|
+
root = paths.DATA_DIR / "operations"
|
|
34
|
+
try:
|
|
35
|
+
files = sorted(root.glob("operations-*.jsonl"))
|
|
36
|
+
except OSError:
|
|
37
|
+
files = []
|
|
38
|
+
for fp in files:
|
|
39
|
+
try:
|
|
40
|
+
for line in fp.read_text(encoding="utf-8").splitlines():
|
|
41
|
+
try:
|
|
42
|
+
row = json.loads(line)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(row, dict) and row.get("operation_id"):
|
|
46
|
+
rows.append(row)
|
|
47
|
+
except OSError:
|
|
48
|
+
continue
|
|
49
|
+
return rows
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write_all(rows):
|
|
53
|
+
"""Test/support migration helper; production writes use append-only _append."""
|
|
54
|
+
root = paths.DATA_DIR / "operations"
|
|
55
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
fp = _file()
|
|
57
|
+
fp.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows),
|
|
58
|
+
encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _append(row):
|
|
62
|
+
try:
|
|
63
|
+
fp = _file(row.get("submitted_at") and
|
|
64
|
+
time.strftime("%Y-%m-%d", time.localtime(float(row["submitted_at"]))))
|
|
65
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
with fp.open("a", encoding="utf-8") as handle:
|
|
67
|
+
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
|
68
|
+
handle.flush()
|
|
69
|
+
except Exception:
|
|
70
|
+
# Observability must not turn a remote write into a local failure.
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _request_hash(request) -> str:
|
|
75
|
+
raw = json.dumps(request or {}, ensure_ascii=False, sort_keys=True,
|
|
76
|
+
separators=(",", ":"), default=str)
|
|
77
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _operation_id(target: str) -> str:
|
|
81
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
82
|
+
return "op-%s-%s-%s" % (stamp, secrets.token_hex(4),
|
|
83
|
+
hashlib.sha256(str(target).encode()).hexdigest()[:6])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _safe_meta(metadata):
|
|
87
|
+
if not isinstance(metadata, dict):
|
|
88
|
+
return {}
|
|
89
|
+
out = {}
|
|
90
|
+
for key, value in metadata.items():
|
|
91
|
+
name = str(key)[:40]
|
|
92
|
+
if any(token in name.lower() for token in ("key", "token", "password", "secret")):
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
95
|
+
out[name] = str(value)[:300] if isinstance(value, str) else value
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def begin(target, request=None, *, task_id="", run_id="", metadata=None):
|
|
100
|
+
operation_id = _operation_id(target)
|
|
101
|
+
now = time.time()
|
|
102
|
+
row = {
|
|
103
|
+
"operation_id": operation_id,
|
|
104
|
+
"target": str(target or "")[:160],
|
|
105
|
+
"request_hash": _request_hash(request),
|
|
106
|
+
"submitted_at": now,
|
|
107
|
+
"status": "pending",
|
|
108
|
+
"remote_receipt": "",
|
|
109
|
+
"error": "",
|
|
110
|
+
"last_reconciled_at": None,
|
|
111
|
+
"task_id": str(task_id or "")[:80],
|
|
112
|
+
"run_id": str(run_id or "")[:80],
|
|
113
|
+
"metadata": _safe_meta(metadata),
|
|
114
|
+
}
|
|
115
|
+
with LOCK:
|
|
116
|
+
_append(row)
|
|
117
|
+
return operation_id
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def update(operation_id, status, *, remote_receipt="", error="",
|
|
121
|
+
metadata=None, reconciled=False):
|
|
122
|
+
operation_id = str(operation_id or "").strip()
|
|
123
|
+
status = str(status or "").strip().lower()
|
|
124
|
+
if not operation_id or status not in STATUSES:
|
|
125
|
+
return False
|
|
126
|
+
with LOCK:
|
|
127
|
+
# Read and transition-check under the same lock as append. Two
|
|
128
|
+
# concurrent callbacks must not race a pending operation into
|
|
129
|
+
# conflicting terminal states.
|
|
130
|
+
current = get(operation_id)
|
|
131
|
+
if current is None:
|
|
132
|
+
return False
|
|
133
|
+
current_status = str(current.get("status") or "pending")
|
|
134
|
+
# External effects are monotonic: a confirmed write must never be
|
|
135
|
+
# downgraded by cleanup/error handling. Unknown/failed can only be
|
|
136
|
+
# promoted by an explicit remote reconciliation.
|
|
137
|
+
if current_status == "confirmed" and status != "confirmed":
|
|
138
|
+
return False
|
|
139
|
+
if current_status in ("unknown", "failed") and status == "confirmed" and not reconciled:
|
|
140
|
+
return False
|
|
141
|
+
if current_status in ("unknown", "failed") and status != current_status and not reconciled:
|
|
142
|
+
return False
|
|
143
|
+
if status == "confirmed" and not remote_receipt:
|
|
144
|
+
remote_receipt = current.get("remote_receipt") or ""
|
|
145
|
+
now = time.time()
|
|
146
|
+
row = {
|
|
147
|
+
"operation_id": operation_id,
|
|
148
|
+
"status": status,
|
|
149
|
+
"remote_receipt": str(remote_receipt or "")[:300],
|
|
150
|
+
"error": str(error or "")[:500],
|
|
151
|
+
"last_reconciled_at": now if reconciled else None,
|
|
152
|
+
"updated_at": now,
|
|
153
|
+
}
|
|
154
|
+
if metadata:
|
|
155
|
+
row["metadata"] = _safe_meta(metadata)
|
|
156
|
+
_append(row)
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def confirm(operation_id, remote_receipt="", *, metadata=None):
|
|
161
|
+
return update(operation_id, "confirmed", remote_receipt=remote_receipt,
|
|
162
|
+
metadata=metadata)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fail(operation_id, error="", *, metadata=None):
|
|
166
|
+
return update(operation_id, "failed", error=error, metadata=metadata)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def mark_unknown(operation_id, error="", *, metadata=None):
|
|
170
|
+
return update(operation_id, "unknown", error=error, metadata=metadata)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def reconcile(operation_id, status, remote_receipt="", error="", metadata=None):
|
|
174
|
+
"""Record a remote lookup result for an unknown operation."""
|
|
175
|
+
return update(operation_id, status, remote_receipt=remote_receipt, error=error,
|
|
176
|
+
metadata=metadata, reconciled=True)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get(operation_id):
|
|
180
|
+
operation_id = str(operation_id or "")
|
|
181
|
+
latest = None
|
|
182
|
+
with LOCK:
|
|
183
|
+
for row in _read_all():
|
|
184
|
+
if row.get("operation_id") == operation_id:
|
|
185
|
+
latest = dict(latest or {}, **row)
|
|
186
|
+
return latest
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def recent(*, target="", status="", limit=100):
|
|
190
|
+
latest = {}
|
|
191
|
+
with LOCK:
|
|
192
|
+
for row in _read_all():
|
|
193
|
+
if target and str(row.get("target") or "") != str(target):
|
|
194
|
+
continue
|
|
195
|
+
latest[row["operation_id"]] = dict(latest.get(row["operation_id"]) or {}, **row)
|
|
196
|
+
rows = list(latest.values())
|
|
197
|
+
if status:
|
|
198
|
+
rows = [r for r in rows if r.get("status") == status]
|
|
199
|
+
rows.sort(key=lambda r: float(r.get("updated_at") or r.get("submitted_at") or 0), reverse=True)
|
|
200
|
+
return rows[:max(1, min(1000, int(limit or 100)))]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def recover_pending(*, now=None, ttl_seconds=PENDING_TTL_SECONDS):
|
|
204
|
+
"""Turn stale pending writes into unknown so restart never replays blindly."""
|
|
205
|
+
now = time.time() if now is None else float(now)
|
|
206
|
+
changed = 0
|
|
207
|
+
for row in recent(status="pending", limit=1000):
|
|
208
|
+
submitted = float(row.get("submitted_at") or 0)
|
|
209
|
+
if submitted and now - submitted > max(1, float(ttl_seconds)):
|
|
210
|
+
if mark_unknown(row["operation_id"], "进程重启后未收到远端回执,请先对账"):
|
|
211
|
+
changed += 1
|
|
212
|
+
return changed
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def status_for_exception(error) -> str:
|
|
216
|
+
text = str(error or "").lower()
|
|
217
|
+
if isinstance(error, (TimeoutError, ConnectionError, urllib.error.URLError)) or any(x in text for x in
|
|
218
|
+
("timeout", "timed out", "超时", "connection", "connect", "connection reset",
|
|
219
|
+
"断开", "断连", "连不上", "网络", "network", "unreachable", "disconnect")):
|
|
220
|
+
return "unknown"
|
|
221
|
+
return "failed"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def finish_exception(operation_id, error):
|
|
225
|
+
status = status_for_exception(error)
|
|
226
|
+
if status == "unknown":
|
|
227
|
+
return mark_unknown(operation_id, str(error))
|
|
228
|
+
return fail(operation_id, str(error))
|