weflow-cli 1.6.1 → 1.6.2

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.
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env python3
2
+ """Periodic health check for weflow-cli.
3
+
4
+ A pure script - no model, no tokens. Every check here exists because that exact
5
+ thing has silently broken at least once, and the failure was invisible:
6
+
7
+ 版本一致 1.6.0 自报 1.5.1, so "which build am I running" was unanswerable
8
+ 读取路径一致 只读 message_0.db 时, export json 有 31 条而 export html 有 1145 条
9
+ 会话新鲜度 同上, 会话列表停在 18 天前而命令仍然"成功"
10
+ 导出格式 excel 全程从未成功过, 报错只有"导出失败"四个字
11
+ Python 依赖 缺 sqlcipher3 时命令报的是"数据库连接失败"
12
+ 计划任务 提醒脚本静默停跑, 没人会发现
13
+
14
+ Run it ad hoc, or schedule it and read the summary line:
15
+
16
+ py scripts/health_check.py
17
+ py scripts/health_check.py --json
18
+
19
+ Exit code is 0 only when every check passes, so a scheduler can act on it.
20
+ """
21
+ import argparse
22
+ import datetime
23
+ import json
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ import time
30
+ from pathlib import Path
31
+
32
+ ROOT = Path(__file__).resolve().parent.parent
33
+ CLI = ROOT / 'cli.cjs'
34
+
35
+ # Windows consoles default to a legacy codepage that cannot encode the report.
36
+ for _stream in (sys.stdout, sys.stderr):
37
+ try:
38
+ _stream.reconfigure(encoding='utf-8', errors='replace')
39
+ except (AttributeError, ValueError):
40
+ pass
41
+
42
+ # Each check shells out to the CLI, which takes seconds; keep the sample small.
43
+ SAMPLE_LIMIT = 5
44
+ TIMEOUT = 300
45
+
46
+ # A session list that has not moved in this long means reads are stale. WeChat
47
+ # is not chatty for everyone, so this is deliberately generous.
48
+ STALE_SESSION_DAYS = 7
49
+
50
+
51
+ def run(args, timeout=TIMEOUT):
52
+ """(returncode, stdout, stderr) with Node's experimental warning suppressed."""
53
+ env = {**os.environ, 'NODE_NO_WARNINGS': '1', 'PYTHONIOENCODING': 'utf-8'}
54
+ try:
55
+ proc = subprocess.run(
56
+ ['node', str(CLI), *args], capture_output=True, timeout=timeout,
57
+ encoding='utf-8', errors='replace', env=env, cwd=str(ROOT))
58
+ except (OSError, subprocess.SubprocessError) as error:
59
+ return 1, '', f'{type(error).__name__}: {error}'
60
+ return proc.returncode, proc.stdout or '', proc.stderr or ''
61
+
62
+
63
+ def run_json(args):
64
+ """Parsed JSON payload, or None.
65
+
66
+ The CLI pretty-prints, so the payload spans many lines - parse from the
67
+ first brace rather than looking for a single-line blob.
68
+ """
69
+ code, out, _ = run([*args, '--json'])
70
+ start = out.find('{')
71
+ if start < 0:
72
+ return code, None
73
+ try:
74
+ return code, json.JSONDecoder().raw_decode(out[start:])[0]
75
+ except ValueError:
76
+ return code, None
77
+
78
+
79
+ class Report:
80
+ def __init__(self):
81
+ self.rows = []
82
+
83
+ def add(self, name, ok, detail, hint=''):
84
+ self.rows.append({'name': name, 'ok': bool(ok), 'detail': detail, 'hint': hint})
85
+
86
+ @property
87
+ def failed(self):
88
+ return [r for r in self.rows if not r['ok']]
89
+
90
+ def render(self):
91
+ stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
92
+ lines = [f'WeFlow 健康检查 · {stamp}', '']
93
+ width = max(len(r['name']) for r in self.rows) + 2
94
+ for row in self.rows:
95
+ mark = 'OK ' if row['ok'] else 'FAIL'
96
+ lines.append(f'[{mark}] {row["name"]:<{width}}{row["detail"]}')
97
+ if not row['ok'] and row['hint']:
98
+ lines.append(f'{"":<7}{"":<{width}}↳ {row["hint"]}')
99
+ lines.append('')
100
+ if self.failed:
101
+ lines.append(f'{len(self.rows) - len(self.failed)} 项通过 / {len(self.failed)} 项失败')
102
+ else:
103
+ lines.append(f'全部通过({len(self.rows)} 项)')
104
+ return '\n'.join(lines)
105
+
106
+
107
+ def check_version(report):
108
+ """The CLI must report the version it actually is."""
109
+ try:
110
+ declared = json.loads((ROOT / 'package.json').read_text(encoding='utf-8'))['version']
111
+ except (OSError, ValueError, KeyError) as error:
112
+ report.add('版本一致', False, f'读不到 package.json ({type(error).__name__})')
113
+ return
114
+ code, out, _ = run(['--version'])
115
+ reported = out.strip().split('\n')[-1].strip() if out.strip() else ''
116
+ report.add('版本一致', code == 0 and reported == declared,
117
+ f'CLI {reported or "?"} / package {declared}',
118
+ '两者不一致说明 --version 又写死了,或 dist 未重新构建')
119
+
120
+
121
+ def check_environment(report):
122
+ """Environment self-check: config present, Python deps importable."""
123
+ _, data = run_json(['check'])
124
+ if not data:
125
+ report.add('环境自检', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
126
+ return
127
+ config = data.get('configuration') or {}
128
+ deps = ((data.get('dependencies') or {}).get('required') or {})
129
+ missing = [name for name, status in deps.items() if not status]
130
+ ok = bool(config.get('initialized')) and bool(config.get('messageDatabase')) and not missing
131
+ detail = f'已初始化={config.get("initialized")} 消息库={config.get("messageDatabase")}'
132
+ if missing:
133
+ detail += f' 缺依赖={",".join(missing)}'
134
+ report.add('环境自检', ok, detail,
135
+ '缺依赖: pip install -r requirements.txt;未初始化: weflow-cli init')
136
+
137
+
138
+ def pick_talkers(report):
139
+ """The most recently active sessions - the likeliest to span shards."""
140
+ _, data = run_json(['sessions', '--limit', str(SAMPLE_LIMIT)])
141
+ sessions = ((data or {}).get('sessions') or [])
142
+ if not sessions:
143
+ report.add('选择样本会话', False, '会话列表为空', '先确认微信已登录且 init 成功')
144
+ return []
145
+ ordered = sorted(sessions, key=lambda s: s.get('sortTimestamp') or 0, reverse=True)
146
+ return [s['username'] for s in ordered[:2] if s.get('username')]
147
+
148
+
149
+ def check_read_paths(report, talker):
150
+ """`export json` and `export html` read the same chat by different code.
151
+
152
+ They disagreed by 1114 messages when the JSON path read only the first
153
+ shard, so comparing their newest message is the cheapest way to notice the
154
+ read paths drifting apart again.
155
+ """
156
+ with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
157
+ out_json = Path(tmp) / 'json'
158
+ code_j, err_j = run(['export', talker, 'json', '--limit', '5',
159
+ '--output', str(out_json), '--non-interactive'])[0::2]
160
+ newest_json = None
161
+ for path in out_json.glob('*.json'):
162
+ try:
163
+ rows = json.loads(path.read_text(encoding='utf-8'))
164
+ except (OSError, ValueError):
165
+ continue
166
+ times = [int(r.get('createTime') or 0) for r in rows]
167
+ if times:
168
+ newest_json = max(times)
169
+ break
170
+
171
+ out_html = Path(tmp) / 'html'
172
+ code_h, out_h, _ = run(['export', talker, 'html', '--limit', '5',
173
+ '--output', str(out_html), '--non-interactive'])
174
+ total_html = None
175
+ for line in out_h.split('\n'):
176
+ line = line.strip()
177
+ if line.startswith('Total:') and 'messages' in line:
178
+ try:
179
+ total_html = int(line.split()[1])
180
+ except (IndexError, ValueError):
181
+ pass
182
+ # Read the pages while the temp directory still exists.
183
+ newest_html = html_newest_stamp(out_html)
184
+
185
+ label = f'读取路径一致 ({talker[:14]}…)'
186
+ if newest_json is None:
187
+ report.add(label, False, 'json 导出没有产出可比对的消息',
188
+ 'export json 失败时先单独跑一次看报错')
189
+ return
190
+ if total_html is None:
191
+ report.add(label, False, 'html 导出没有报告条数',
192
+ 'export html 失败时先单独跑一次看报错')
193
+ return
194
+
195
+ newest_txt = datetime.datetime.fromtimestamp(newest_json).strftime('%Y-%m-%d %H:%M')
196
+ if newest_html:
197
+ match = newest_html[:16] == newest_txt
198
+ report.add(label, match,
199
+ f'json 最新 {newest_txt} / html 最新 {newest_html}',
200
+ '两条路径读到不同的最新消息,通常又只剩一个分片被读了')
201
+ else:
202
+ # No timestamp in the page (empty chat); the count comparison still holds.
203
+ report.add(label, total_html > 0, f'json 最新 {newest_txt} / html 共 {total_html} 条')
204
+
205
+
206
+ def html_newest_stamp(out_dir):
207
+ """Newest 'YYYY-MM-DD HH:MM:SS' stamp rendered into the exported pages."""
208
+ import re
209
+ newest = ''
210
+ try:
211
+ for page in sorted(Path(out_dir).glob('*.html')):
212
+ text = page.read_text(encoding='utf-8', errors='replace')
213
+ stamps = re.findall(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', text)
214
+ if stamps and stamps[-1] > newest:
215
+ newest = stamps[-1]
216
+ except OSError:
217
+ pass
218
+ return newest
219
+
220
+
221
+ def check_session_freshness(report):
222
+ """A read path that silently stops at an old shard shows up here first."""
223
+ _, data = run_json(['sessions', '--limit', '5'])
224
+ sessions = ((data or {}).get('sessions') or [])
225
+ stamps = [s.get('sortTimestamp') or 0 for s in sessions]
226
+ if not stamps or not max(stamps):
227
+ report.add('会话新鲜度', False, '会话列表没有时间戳', 'init 可能未完成')
228
+ return
229
+ newest = max(stamps)
230
+ age = (time.time() - newest) / 86400
231
+ when = datetime.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M')
232
+ report.add('会话新鲜度', age <= STALE_SESSION_DAYS,
233
+ f'最新会话 {when}({age:.1f} 天前)',
234
+ f'超过 {STALE_SESSION_DAYS} 天未更新,通常意味着读取只覆盖了部分分片')
235
+
236
+
237
+ def check_export_formats(report, talker):
238
+ """json / txt / excel must each produce a file. html is covered above."""
239
+ with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
240
+ for fmt, pattern in (('json', '*.json'), ('txt', '*.txt'), ('excel', '*.xlsx')):
241
+ out = Path(tmp) / fmt
242
+ code, stdout, stderr = run(['export', talker, fmt, '--limit', '3',
243
+ '--output', str(out), '--non-interactive'])
244
+ produced = list(out.glob(pattern)) if out.exists() else []
245
+ size = produced[0].stat().st_size if produced else 0
246
+ detail = f'{size} 字节' if produced else (stderr.strip().split('\n')[-1][:60] or f'exit {code}')
247
+ report.add(f'导出格式 {fmt}', code == 0 and size > 0, detail)
248
+
249
+
250
+ def check_python_deps(report):
251
+ """The NT reader is a Python subprocess; a missing module looks like a DB error."""
252
+ required = {
253
+ 'sqlcipher3': 'sqlcipher3',
254
+ 'html2text': 'html2text',
255
+ 'zstandard': 'zstandard',
256
+ 'cryptography': 'cryptography',
257
+ }
258
+ missing = []
259
+ for module, package in required.items():
260
+ try:
261
+ __import__(module)
262
+ except ImportError:
263
+ missing.append(package)
264
+ report.add('Python 依赖', not missing,
265
+ '齐全' if not missing else f'缺 {", ".join(missing)}',
266
+ 'pip install -r requirements.txt')
267
+
268
+
269
+ def check_scheduled_tasks(report):
270
+ """The watcher tasks must exist and their last run must have succeeded."""
271
+ if os.name != 'nt':
272
+ report.add('计划任务', True, '非 Windows,跳过')
273
+ return
274
+ if not shutil.which('schtasks'):
275
+ report.add('计划任务', True, '找不到 schtasks,跳过')
276
+ return
277
+ names = ['WeFlow Issue Watch', 'WeFlow Mail Watch']
278
+ broken, absent = [], []
279
+ for name in names:
280
+ try:
281
+ proc = subprocess.run(['schtasks', '/query', '/tn', name, '/fo', 'LIST', '/v'],
282
+ capture_output=True, timeout=60, encoding='utf-8', errors='replace')
283
+ except (OSError, subprocess.SubprocessError):
284
+ absent.append(name)
285
+ continue
286
+ if proc.returncode != 0:
287
+ absent.append(name)
288
+ continue
289
+ text = proc.stdout or ''
290
+ for line in text.split('\n'):
291
+ if 'Last Result' in line or '上次运行结果' in line:
292
+ result = line.split(':', 1)[-1].strip()
293
+ if result not in ('0', '0x0'):
294
+ broken.append(f'{name}={result}')
295
+ if broken:
296
+ report.add('计划任务', False, f'上次运行失败: {", ".join(broken)}',
297
+ '脚本可能抛异常了,手动跑一次看输出')
298
+ elif absent:
299
+ report.add('计划任务', False, f'不存在: {", ".join(absent)}',
300
+ '提醒不会触发;见 docs/HEALTH-CHECK.md 的注册命令')
301
+ else:
302
+ report.add('计划任务', True, '均在位且上次成功')
303
+
304
+
305
+ def main():
306
+ parser = argparse.ArgumentParser(description='weflow-cli periodic health check')
307
+ parser.add_argument('--json', action='store_true', help='机器可读输出')
308
+ args = parser.parse_args()
309
+
310
+ if not CLI.exists():
311
+ print(f'找不到 {CLI}', file=sys.stderr)
312
+ return 2
313
+
314
+ report = Report()
315
+ check_version(report)
316
+ check_environment(report)
317
+ check_python_deps(report)
318
+ check_session_freshness(report)
319
+
320
+ talkers = pick_talkers(report)
321
+ if talkers:
322
+ check_read_paths(report, talkers[0])
323
+ check_export_formats(report, talkers[0])
324
+
325
+ check_scheduled_tasks(report)
326
+
327
+ if args.json:
328
+ print(json.dumps({'success': not report.failed, 'checks': report.rows},
329
+ ensure_ascii=False, indent=2))
330
+ else:
331
+ print(report.render())
332
+ return 1 if report.failed else 0
333
+
334
+
335
+ if __name__ == '__main__':
336
+ sys.exit(main())