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.
Files changed (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +209 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -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/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -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))