openclaw-diag-cli 0.1.3 → 0.2.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.
- package/README.md +83 -71
- package/bin/ocdiag +0 -1
- package/bin/openclaw-diag.js +65 -176
- package/diag/01_sys_health.py +0 -2
- package/diag/02_environment.py +32 -6
- package/diag/03_configuration.py +4 -1
- package/diag/04_gateway.py +30 -8
- package/diag/05_recent_errors.py +24 -14
- package/diag/06_cron_jobs.py +4 -41
- package/diag/07_performance.py +114 -42
- package/diag/08_sessions.py +2 -54
- package/diag/09_plugin_diag.py +52 -25
- package/diag/10_shell_history.py +28 -10
- package/lib/__pycache__/bundle.cpython-310.pyc +0 -0
- package/lib/bundle.py +6 -13
- package/ocdiag/__init__.py +1 -1
- package/ocdiag/__pycache__/__init__.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/cli.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/dispatcher.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/doctor.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/jsonlog.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/output.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/paths.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/recent_logs.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/sensitive.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/sessions.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/timeutil.cpython-310.pyc +0 -0
- package/ocdiag/__pycache__/tokens.cpython-310.pyc +0 -0
- package/ocdiag/cli.py +16 -1
- package/ocdiag/dispatcher.py +140 -53
- package/ocdiag/doctor.py +162 -0
- package/ocdiag/jsonlog.py +0 -5
- package/ocdiag/paths.py +0 -17
- package/ocdiag/recent_logs.py +0 -3
- package/ocdiag/sensitive.py +95 -1
- package/ocdiag/sessions.py +161 -0
- package/ocdiag/timeutil.py +0 -11
- package/ocdiag/tokens.py +0 -4
- package/package.json +2 -2
- package/tools/oc_session_extract.py +190 -67
- package/tools/oc_session_trace.py +48 -46
package/lib/bundle.py
CHANGED
|
@@ -21,19 +21,12 @@ from typing import Dict, List, Optional
|
|
|
21
21
|
|
|
22
22
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
("recent_errors", "diag/05_recent_errors.py"),
|
|
31
|
-
("cron_jobs", "diag/06_cron_jobs.py"),
|
|
32
|
-
("performance", "diag/07_performance.py"),
|
|
33
|
-
("sessions", "diag/08_sessions.py"),
|
|
34
|
-
("plugin_diag", "diag/09_plugin_diag.py"),
|
|
35
|
-
("shell_history", "diag/10_shell_history.py"),
|
|
36
|
-
]
|
|
24
|
+
# Single source of truth: ocdiag.dispatcher. We import it dynamically so the
|
|
25
|
+
# bundle script stays runnable from a fresh checkout (no install needed).
|
|
26
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
27
|
+
from ocdiag.dispatcher import STATE_COLLECTORS # noqa: E402
|
|
28
|
+
|
|
29
|
+
MODULES: List[tuple] = [(mid, rel) for mid, _label, rel in STATE_COLLECTORS]
|
|
37
30
|
MODULE_BY_ID = {mid: rel for mid, rel in MODULES}
|
|
38
31
|
|
|
39
32
|
# Order matters: each submodule is exec'd into its own module object, and its
|
package/ocdiag/__init__.py
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ocdiag/cli.py
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
"""Common argparse setup for diag scripts.
|
|
1
|
+
"""Common argparse setup for diag scripts.
|
|
2
|
+
|
|
3
|
+
When invoked via the dispatcher (`openclaw-diag <id>`), the dispatcher exports
|
|
4
|
+
OPENCLAW_DIAG_PROG="openclaw-diag <id>" before running the script so argparse
|
|
5
|
+
uses that as `prog`. When you run the script directly (e.g.
|
|
6
|
+
`python3 diag/01_sys_health.py`), argparse falls back to the script basename.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from __future__ import annotations
|
|
4
10
|
|
|
5
11
|
import argparse
|
|
12
|
+
import os
|
|
6
13
|
from typing import Optional
|
|
7
14
|
|
|
8
15
|
from . import paths
|
|
9
16
|
|
|
10
17
|
|
|
11
18
|
def build_common_parser(description: str, prog: Optional[str] = None) -> argparse.ArgumentParser:
|
|
19
|
+
if prog is None:
|
|
20
|
+
prog = os.environ.get("OPENCLAW_DIAG_PROG") or None
|
|
12
21
|
p = argparse.ArgumentParser(
|
|
13
22
|
prog=prog,
|
|
14
23
|
description=description,
|
|
@@ -36,4 +45,10 @@ def build_common_parser(description: str, prog: Optional[str] = None) -> argpars
|
|
|
36
45
|
)
|
|
37
46
|
p.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
38
47
|
p.add_argument("--no-color", action="store_true", help="Disable colored output")
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--unmask",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Disable default sanitization of secrets in free-form text "
|
|
52
|
+
"(shell history / plugin errors / systemd / sessions). Off by default.",
|
|
53
|
+
)
|
|
39
54
|
return p
|
package/ocdiag/dispatcher.py
CHANGED
|
@@ -5,39 +5,42 @@ Layout:
|
|
|
5
5
|
ocdiag <object-inspector> ARG runs that inspector (e.g. `ocdiag trace UUID`)
|
|
6
6
|
ocdiag all [--skip a,b] runs every state collector
|
|
7
7
|
ocdiag list prints the catalogue grouped by parameter mode
|
|
8
|
-
ocdiag
|
|
8
|
+
ocdiag bundle <id> emits a self-contained single-file .py
|
|
9
|
+
ocdiag doctor environment health check
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
12
13
|
|
|
14
|
+
import json
|
|
13
15
|
import os
|
|
14
16
|
import runpy
|
|
15
17
|
import sys
|
|
16
18
|
import time
|
|
19
|
+
import traceback
|
|
17
20
|
from pathlib import Path
|
|
18
|
-
from typing import List
|
|
21
|
+
from typing import List, Optional, Tuple
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
22
25
|
|
|
23
26
|
# State collectors: zero required args, parameter-free observation of system state.
|
|
24
27
|
STATE_COLLECTORS = [
|
|
25
|
-
("sys_health", "
|
|
26
|
-
("environment", "OpenClaw
|
|
27
|
-
("configuration", "
|
|
28
|
-
("gateway", "Gateway
|
|
29
|
-
("recent_errors", "
|
|
30
|
-
("cron_jobs", "
|
|
31
|
-
("performance", "
|
|
32
|
-
("sessions", "Session
|
|
33
|
-
("plugin_diag", "
|
|
34
|
-
("shell_history", "Shell
|
|
28
|
+
("sys_health", "系统健康(DNS / 网络 / CPU / 内存 / 磁盘 / 进程 / 时间)", "diag/01_sys_health.py"),
|
|
29
|
+
("environment", "OpenClaw 版本、Gateway 进程环境变量", "diag/02_environment.py"),
|
|
30
|
+
("configuration", "openclaw.json 展平(敏感字段脱敏)", "diag/03_configuration.py"),
|
|
31
|
+
("gateway", "Gateway 进程、端口、24h 重启、WS 生命周期、错误码", "diag/04_gateway.py"),
|
|
32
|
+
("recent_errors", "应用日志 / journalctl / session 工具调用错误聚合", "diag/05_recent_errors.py"),
|
|
33
|
+
("cron_jobs", "定时任务状态、连续失败、调度漂移、静默检测", "diag/06_cron_jobs.py"),
|
|
34
|
+
("performance", "模型/工具耗时 P50/P95、慢调用、E2E 延迟、Cache 命中率", "diag/07_performance.py"),
|
|
35
|
+
("sessions", "Session 总览、活跃度、Stuck 探测", "diag/08_sessions.py"),
|
|
36
|
+
("plugin_diag", "插件状态一致性、ERROR/WARN、Hook、Channel、外部 DNS", "diag/09_plugin_diag.py"),
|
|
37
|
+
("shell_history", "Shell 历史中的高危命令与最近操作", "diag/10_shell_history.py"),
|
|
35
38
|
]
|
|
36
39
|
|
|
37
40
|
# Object inspectors: take a session uuid (or other identifier) and inspect it.
|
|
38
41
|
OBJECT_INSPECTORS = [
|
|
39
|
-
("trace", "
|
|
40
|
-
("extract", "导出 session 为可读格式",
|
|
42
|
+
("trace", "追踪一条用户消息从进入到响应的完整时间轴", "tools/oc_session_trace.py"),
|
|
43
|
+
("extract", "导出 session.jsonl 为可读格式", "tools/oc_session_extract.py"),
|
|
41
44
|
]
|
|
42
45
|
|
|
43
46
|
STATE_BY_ID = {mid: (label, script) for mid, label, script in STATE_COLLECTORS}
|
|
@@ -46,31 +49,67 @@ MODULE_BY_ID = {**STATE_BY_ID, **OBJECT_BY_ID}
|
|
|
46
49
|
MODULE_IDS = set(MODULE_BY_ID.keys())
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
def cmd_list_json() -> int:
|
|
53
|
+
"""Machine-readable module catalogue. Single source of truth consumed
|
|
54
|
+
by the Node shell and the bundle script (axiom #3)."""
|
|
55
|
+
payload = {
|
|
56
|
+
"state_collectors": [
|
|
57
|
+
{"id": mid, "label": label, "script": rel}
|
|
58
|
+
for mid, label, rel in STATE_COLLECTORS
|
|
59
|
+
],
|
|
60
|
+
"object_inspectors": [
|
|
61
|
+
{"id": mid, "label": label, "script": rel}
|
|
62
|
+
for mid, label, rel in OBJECT_INSPECTORS
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
49
69
|
def cmd_list() -> int:
|
|
50
|
-
print("
|
|
70
|
+
print("openclaw-diag — 可用诊断")
|
|
51
71
|
print()
|
|
52
|
-
print("
|
|
72
|
+
print(" 扫描类(无需参数):")
|
|
53
73
|
for mid, label, _ in STATE_COLLECTORS:
|
|
54
74
|
print(f" {mid:<16s} {label}")
|
|
55
75
|
print()
|
|
56
|
-
print("
|
|
76
|
+
print(" 对象类(需要 session uuid):")
|
|
57
77
|
for mid, label, _ in OBJECT_INSPECTORS:
|
|
58
78
|
print(f" {mid:<16s} {label}")
|
|
59
79
|
print()
|
|
60
|
-
print("
|
|
61
|
-
print(" all
|
|
62
|
-
print(" doctor 检查 Node/Python/OpenClaw 环境")
|
|
63
|
-
print(" bundle <id>
|
|
80
|
+
print(" 其它命令:")
|
|
81
|
+
print(" all 一次跑完所有扫描类")
|
|
82
|
+
print(" doctor 检查 Node / Python / openclaw-diag / OpenClaw 环境")
|
|
83
|
+
print(" bundle <id> 生成 self-contained 单文件 .py(离线机器用)")
|
|
64
84
|
return 0
|
|
65
85
|
|
|
66
86
|
|
|
67
|
-
def run_script(
|
|
87
|
+
def run_script(
|
|
88
|
+
script_rel: str,
|
|
89
|
+
extra_args: List[str],
|
|
90
|
+
module_id: Optional[str] = None,
|
|
91
|
+
) -> int:
|
|
92
|
+
"""Execute a diag script in-process. Returns the rc.
|
|
93
|
+
|
|
94
|
+
On crash, in addition to the human-readable stderr trace we emit a single
|
|
95
|
+
NDJSON error record to stdout when the script was invoked with --json.
|
|
96
|
+
This guarantees `all --json` produces N records for N modules — including
|
|
97
|
+
crashes — so downstream parsers don't silently lose modules. (Axiom #4)
|
|
98
|
+
"""
|
|
68
99
|
script_path = REPO_ROOT / script_rel
|
|
69
100
|
if not script_path.is_file():
|
|
70
101
|
print(f"Error: script not found: {script_path}", file=sys.stderr)
|
|
71
102
|
return 2
|
|
103
|
+
json_mode = "--json" in extra_args
|
|
104
|
+
mid = module_id or script_path.stem
|
|
72
105
|
saved_argv = sys.argv[:]
|
|
106
|
+
saved_prog = os.environ.get("OPENCLAW_DIAG_PROG")
|
|
73
107
|
try:
|
|
108
|
+
# runpy.run_path resets sys.argv[0] to the script path, so we
|
|
109
|
+
# advertise the user-facing name through an env var instead. cli.py
|
|
110
|
+
# picks it up as the argparse prog so --help reads as
|
|
111
|
+
# "openclaw-diag sys_health" rather than "01_sys_health.py".
|
|
112
|
+
os.environ["OPENCLAW_DIAG_PROG"] = f"openclaw-diag {mid}"
|
|
74
113
|
sys.argv = [str(script_path), *extra_args]
|
|
75
114
|
runpy.run_path(str(script_path), run_name="__main__")
|
|
76
115
|
return 0
|
|
@@ -79,14 +118,32 @@ def run_script(script_rel: str, extra_args: List[str]) -> int:
|
|
|
79
118
|
return int(e.code) if e.code is not None else 0
|
|
80
119
|
except (TypeError, ValueError):
|
|
81
120
|
return 1
|
|
82
|
-
except
|
|
121
|
+
except BaseException as e: # noqa: BLE001 — emit then re-classify
|
|
83
122
|
print(f" ERROR: {script_path.name} crashed: {type(e).__name__}: {e}",
|
|
84
123
|
file=sys.stderr)
|
|
85
|
-
import traceback
|
|
86
124
|
traceback.print_exc(file=sys.stderr)
|
|
125
|
+
if json_mode:
|
|
126
|
+
err_record = {
|
|
127
|
+
"module": mid,
|
|
128
|
+
"status": "error",
|
|
129
|
+
"error": f"{type(e).__name__}: {e}",
|
|
130
|
+
"traceback": traceback.format_exc(),
|
|
131
|
+
}
|
|
132
|
+
try:
|
|
133
|
+
sys.stdout.write(json.dumps(err_record, ensure_ascii=False) + "\n")
|
|
134
|
+
sys.stdout.flush()
|
|
135
|
+
except Exception:
|
|
136
|
+
# If stdout itself is broken (closed pipe), there's nothing
|
|
137
|
+
# productive to do — the stderr trace above already records
|
|
138
|
+
# the crash.
|
|
139
|
+
pass
|
|
87
140
|
return 2
|
|
88
141
|
finally:
|
|
89
142
|
sys.argv = saved_argv
|
|
143
|
+
if saved_prog is None:
|
|
144
|
+
os.environ.pop("OPENCLAW_DIAG_PROG", None)
|
|
145
|
+
else:
|
|
146
|
+
os.environ["OPENCLAW_DIAG_PROG"] = saved_prog
|
|
90
147
|
|
|
91
148
|
|
|
92
149
|
def cmd_all(extra_args: List[str], skip_ids: List[str]) -> int:
|
|
@@ -101,7 +158,7 @@ def cmd_all(extra_args: List[str], skip_ids: List[str]) -> int:
|
|
|
101
158
|
n += 1
|
|
102
159
|
print(f"\n[{n}/{total}] {label} ({mid})...", flush=True, file=progress_stream)
|
|
103
160
|
t0 = time.time()
|
|
104
|
-
rc = run_script(script, extra_args)
|
|
161
|
+
rc = run_script(script, extra_args, module_id=mid)
|
|
105
162
|
elapsed = time.time() - t0
|
|
106
163
|
print(f"[{n}/{total}] {label} ({mid}) ... done ({elapsed:.1f}s)",
|
|
107
164
|
flush=True, file=progress_stream)
|
|
@@ -110,7 +167,27 @@ def cmd_all(extra_args: List[str], skip_ids: List[str]) -> int:
|
|
|
110
167
|
return rc_overall
|
|
111
168
|
|
|
112
169
|
|
|
113
|
-
def
|
|
170
|
+
def cmd_bundle(rest: List[str]) -> int:
|
|
171
|
+
"""Generate a self-contained single-file diag script.
|
|
172
|
+
|
|
173
|
+
Lives here (rather than in lib/bundle.py only) so the Python entry has
|
|
174
|
+
parity with Node — `python3 bin/ocdiag bundle gateway` works the same as
|
|
175
|
+
`node bin/openclaw-diag.js bundle gateway`. (Axiom #3)
|
|
176
|
+
"""
|
|
177
|
+
if not rest or rest[0] in ("-h", "--help"):
|
|
178
|
+
print("Usage: openclaw-diag bundle <id>", file=sys.stderr)
|
|
179
|
+
print(" Emits the bundle to stdout. Use shell redirection to save.", file=sys.stderr)
|
|
180
|
+
print(file=sys.stderr)
|
|
181
|
+
print("Available ids:", file=sys.stderr)
|
|
182
|
+
for mid, _label, _ in STATE_COLLECTORS:
|
|
183
|
+
print(f" {mid}", file=sys.stderr)
|
|
184
|
+
return 0 if rest else 2
|
|
185
|
+
sys.path.insert(0, str(REPO_ROOT / "lib"))
|
|
186
|
+
import bundle # type: ignore
|
|
187
|
+
return bundle.main(rest)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _split_skip(rest: List[str]) -> Tuple[List[str], List[str]]:
|
|
114
191
|
"""Pull out --skip a,b out of an argv tail; return (skip_ids, passthrough)."""
|
|
115
192
|
skip_ids: List[str] = []
|
|
116
193
|
passthrough: List[str] = []
|
|
@@ -126,22 +203,30 @@ def _split_skip(rest: List[str]) -> (List[str], List[str]):
|
|
|
126
203
|
return skip_ids, passthrough
|
|
127
204
|
|
|
128
205
|
|
|
206
|
+
def _suggest_command(unknown: str) -> Optional[str]:
|
|
207
|
+
"""Best-effort typo suggestion for a misspelled command."""
|
|
208
|
+
import difflib
|
|
209
|
+
candidates = list(MODULE_BY_ID.keys()) + ["all", "list", "doctor", "bundle"]
|
|
210
|
+
matches = difflib.get_close_matches(unknown, candidates, n=1, cutoff=0.6)
|
|
211
|
+
return matches[0] if matches else None
|
|
212
|
+
|
|
213
|
+
|
|
129
214
|
def print_help() -> None:
|
|
130
|
-
print("
|
|
215
|
+
print("openclaw-diag — OpenClaw 诊断工具箱")
|
|
131
216
|
print()
|
|
132
|
-
print("
|
|
133
|
-
print("
|
|
134
|
-
print("
|
|
135
|
-
print("
|
|
136
|
-
print("
|
|
217
|
+
print("用法:")
|
|
218
|
+
print(" openclaw-diag <id> [args...] 跑单个诊断")
|
|
219
|
+
print(" openclaw-diag all [--skip a,b] 跑全部 state collectors")
|
|
220
|
+
print(" openclaw-diag list 列出所有诊断")
|
|
221
|
+
print(" openclaw-diag doctor 检查环境")
|
|
222
|
+
print(" openclaw-diag bundle <id> 生成单文件 .py")
|
|
137
223
|
print()
|
|
138
|
-
print("
|
|
224
|
+
print("扫描类(无需参数):")
|
|
139
225
|
print(" " + " ".join(mid for mid, _, _ in STATE_COLLECTORS))
|
|
140
|
-
print("
|
|
226
|
+
print("对象类(需要 session uuid):")
|
|
141
227
|
print(" " + " ".join(mid for mid, _, _ in OBJECT_INSPECTORS))
|
|
142
228
|
print()
|
|
143
|
-
print("--
|
|
144
|
-
print("其它参数(--config / --log-dir / --json / --no-color)原样传递给脚本。")
|
|
229
|
+
print("常用 flag:--json(结构化输出) --no-color(关掉颜色) --unmask(不脱敏)")
|
|
145
230
|
|
|
146
231
|
|
|
147
232
|
def main(argv=None) -> int:
|
|
@@ -154,33 +239,35 @@ def main(argv=None) -> int:
|
|
|
154
239
|
head, rest = argv[0], argv[1:]
|
|
155
240
|
|
|
156
241
|
if head == "list":
|
|
242
|
+
if "--json" in rest:
|
|
243
|
+
return cmd_list_json()
|
|
157
244
|
return cmd_list()
|
|
158
245
|
|
|
246
|
+
if head == "doctor":
|
|
247
|
+
from ocdiag import doctor
|
|
248
|
+
json_mode = "--json" in rest
|
|
249
|
+
node_version = None
|
|
250
|
+
for i, a in enumerate(rest):
|
|
251
|
+
if a == "--node-version" and i + 1 < len(rest):
|
|
252
|
+
node_version = rest[i + 1]
|
|
253
|
+
break
|
|
254
|
+
return doctor.run(json_mode=json_mode, node_version=node_version)
|
|
255
|
+
|
|
159
256
|
if head == "all":
|
|
160
257
|
skip_ids, passthrough = _split_skip(rest)
|
|
161
258
|
return cmd_all(passthrough, skip_ids)
|
|
162
259
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if not rest:
|
|
166
|
-
print("Error: run requires a target (module id or 'all').", file=sys.stderr)
|
|
167
|
-
return 2
|
|
168
|
-
target, sub = rest[0], rest[1:]
|
|
169
|
-
if target == "all":
|
|
170
|
-
skip_ids, passthrough = _split_skip(sub)
|
|
171
|
-
return cmd_all(passthrough, skip_ids)
|
|
172
|
-
if target in MODULE_BY_ID:
|
|
173
|
-
_, script = MODULE_BY_ID[target]
|
|
174
|
-
return run_script(script, sub)
|
|
175
|
-
print(f"Error: unknown diagnostic '{target}'. Use `ocdiag list`.", file=sys.stderr)
|
|
176
|
-
return 2
|
|
260
|
+
if head == "bundle":
|
|
261
|
+
return cmd_bundle(rest)
|
|
177
262
|
|
|
178
263
|
if head in MODULE_BY_ID:
|
|
179
264
|
_, script = MODULE_BY_ID[head]
|
|
180
|
-
return run_script(script, rest)
|
|
265
|
+
return run_script(script, rest, module_id=head)
|
|
181
266
|
|
|
182
|
-
|
|
183
|
-
|
|
267
|
+
suggestion = _suggest_command(head)
|
|
268
|
+
hint = f"(你是不是想说 `{suggestion}`?)" if suggestion else ""
|
|
269
|
+
print(f"Error: 未知命令 '{head}'{hint}", file=sys.stderr)
|
|
270
|
+
print(f"运行 `openclaw-diag list` 查看全部诊断。", file=sys.stderr)
|
|
184
271
|
return 2
|
|
185
272
|
|
|
186
273
|
|
package/ocdiag/doctor.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``ocdiag doctor`` — environment health-check.
|
|
2
|
+
|
|
3
|
+
Sole authoritative implementation. Both Node (`bin/openclaw-diag.js doctor`)
|
|
4
|
+
and Python (`bin/ocdiag doctor` / `python3 -m ocdiag.doctor`) entry points
|
|
5
|
+
call this function. The Node entry is now a thin spawn wrapper.
|
|
6
|
+
|
|
7
|
+
Checks:
|
|
8
|
+
- Python version (>= 3.8)
|
|
9
|
+
- ocdiag package importable + version
|
|
10
|
+
- All registered diag scripts respond to ``--help``
|
|
11
|
+
- openclaw.json exists at expected path
|
|
12
|
+
|
|
13
|
+
Node version isn't visible from Python so we accept it as a passthrough
|
|
14
|
+
argument; if absent, doctor reports node check as ``skipped``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _node_status(node_version: Optional[str]) -> dict:
|
|
31
|
+
if not node_version:
|
|
32
|
+
return {"version": None, "ok": True, "skipped": True,
|
|
33
|
+
"reason": "Node check is performed by the Node entry; "
|
|
34
|
+
"ocdiag is fine without Node when invoked from Python"}
|
|
35
|
+
# Normalize: accept "v22.22.2" or "22.22.2"
|
|
36
|
+
normalized = node_version.lstrip("v")
|
|
37
|
+
try:
|
|
38
|
+
major = int(normalized.split(".", 1)[0])
|
|
39
|
+
except ValueError:
|
|
40
|
+
return {"version": normalized, "ok": False, "reason": "unparseable"}
|
|
41
|
+
return {"version": normalized, "ok": major >= 18,
|
|
42
|
+
"required": ">=18"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _python_status() -> dict:
|
|
46
|
+
v = sys.version_info
|
|
47
|
+
return {
|
|
48
|
+
"version": f"{v.major}.{v.minor}.{v.micro}",
|
|
49
|
+
"ok": v >= (3, 8),
|
|
50
|
+
"required": ">=3.8",
|
|
51
|
+
"executable": sys.executable,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ocdiag_status() -> dict:
|
|
56
|
+
try:
|
|
57
|
+
import ocdiag # type: ignore
|
|
58
|
+
return {"ok": True, "version": getattr(ocdiag, "__version__", "?")}
|
|
59
|
+
except ImportError as e:
|
|
60
|
+
return {"ok": False, "error": str(e)[:200]}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _diag_scripts_status() -> dict:
|
|
64
|
+
from ocdiag.dispatcher import STATE_COLLECTORS, OBJECT_INSPECTORS
|
|
65
|
+
failed = []
|
|
66
|
+
all_scripts = []
|
|
67
|
+
for mid, _label, rel in (*STATE_COLLECTORS, *OBJECT_INSPECTORS):
|
|
68
|
+
all_scripts.append((mid, REPO_ROOT / rel))
|
|
69
|
+
for mid, path in all_scripts:
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
failed.append({"script": mid, "reason": "missing", "path": str(path)})
|
|
72
|
+
continue
|
|
73
|
+
r = subprocess.run(
|
|
74
|
+
[sys.executable, str(path), "--help"],
|
|
75
|
+
capture_output=True, text=True, timeout=10, check=False,
|
|
76
|
+
)
|
|
77
|
+
if r.returncode != 0:
|
|
78
|
+
failed.append({
|
|
79
|
+
"script": mid,
|
|
80
|
+
"rc": r.returncode,
|
|
81
|
+
"stderr": (r.stderr or "")[:200],
|
|
82
|
+
})
|
|
83
|
+
return {"ok": not failed, "total": len(all_scripts), "failed": failed}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _openclaw_config_status() -> dict:
|
|
87
|
+
home = os.path.expanduser("~")
|
|
88
|
+
cfg = os.environ.get("OPENCLAW_CONFIG") or os.path.join(
|
|
89
|
+
os.environ.get("OPENCLAW_HOME", os.path.join(home, ".openclaw")),
|
|
90
|
+
"openclaw.json",
|
|
91
|
+
)
|
|
92
|
+
return {"path": cfg, "exists": os.path.isfile(cfg)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run(json_mode: bool = False, node_version: Optional[str] = None) -> int:
|
|
96
|
+
"""Execute the doctor check. Returns rc (0 if everything OK, 1 otherwise)."""
|
|
97
|
+
result = {
|
|
98
|
+
"node": _node_status(node_version),
|
|
99
|
+
"python": _python_status(),
|
|
100
|
+
"ocdiag": _ocdiag_status(),
|
|
101
|
+
"diag_scripts": _diag_scripts_status(),
|
|
102
|
+
"openclaw_config": _openclaw_config_status(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ok = (
|
|
106
|
+
result["node"].get("ok", True)
|
|
107
|
+
and result["python"]["ok"]
|
|
108
|
+
and result["ocdiag"]["ok"]
|
|
109
|
+
and result["diag_scripts"]["ok"]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if json_mode:
|
|
113
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
114
|
+
else:
|
|
115
|
+
node = result["node"]
|
|
116
|
+
if node.get("skipped"):
|
|
117
|
+
print(f"ℹ Node check skipped (run via npx to verify Node version)")
|
|
118
|
+
elif node["ok"]:
|
|
119
|
+
print(f"✓ Node v{node['version']}")
|
|
120
|
+
else:
|
|
121
|
+
print(f"✗ Node v{node.get('version','?')} (need {node.get('required','?')})")
|
|
122
|
+
|
|
123
|
+
py = result["python"]
|
|
124
|
+
mark = "✓" if py["ok"] else "✗"
|
|
125
|
+
print(f"{mark} Python {py['version']} ({py['executable']})")
|
|
126
|
+
|
|
127
|
+
oc = result["ocdiag"]
|
|
128
|
+
if oc["ok"]:
|
|
129
|
+
print(f"✓ ocdiag package importable (version {oc['version']})")
|
|
130
|
+
else:
|
|
131
|
+
print(f"✗ ocdiag package not importable: {oc.get('error','?')}")
|
|
132
|
+
|
|
133
|
+
ds = result["diag_scripts"]
|
|
134
|
+
if ds["ok"]:
|
|
135
|
+
print(f"✓ All {ds['total']} diagnostics respond to --help")
|
|
136
|
+
else:
|
|
137
|
+
print(f"✗ {len(ds['failed'])}/{ds['total']} diagnostics failed --help:")
|
|
138
|
+
for f in ds["failed"]:
|
|
139
|
+
print(f" {f.get('script','?')} (rc={f.get('rc','?')})")
|
|
140
|
+
|
|
141
|
+
cfg = result["openclaw_config"]
|
|
142
|
+
if cfg["exists"]:
|
|
143
|
+
print(f"✓ OpenClaw config present ({cfg['path']})")
|
|
144
|
+
else:
|
|
145
|
+
print(f"ℹ OpenClaw config not found ({cfg['path']}) — diagnostics will run but report missing")
|
|
146
|
+
|
|
147
|
+
return 0 if ok else 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv=None) -> int:
|
|
151
|
+
p = argparse.ArgumentParser(prog="ocdiag-doctor",
|
|
152
|
+
description="Health-check the ocdiag install + environment")
|
|
153
|
+
p.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
154
|
+
p.add_argument("--node-version", default=None,
|
|
155
|
+
help="Node version string (e.g. '20.12.1') passed in by the Node "
|
|
156
|
+
"shell. Omit when running from Python directly.")
|
|
157
|
+
args = p.parse_args(argv)
|
|
158
|
+
return run(json_mode=args.json, node_version=args.node_version)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
sys.exit(main())
|
package/ocdiag/jsonlog.py
CHANGED
|
@@ -58,8 +58,3 @@ def parse_name(obj: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
|
|
58
58
|
return p.get("plugin"), p.get("subsystem")
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def log_level(obj: Dict[str, Any]) -> str:
|
|
62
|
-
meta = obj.get("_meta") or {}
|
|
63
|
-
if isinstance(meta, dict):
|
|
64
|
-
return meta.get("logLevelName", "") or ""
|
|
65
|
-
return ""
|
package/ocdiag/paths.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
def _env_path(name: str, default: str) -> str:
|
|
@@ -30,19 +29,3 @@ SERVICE_ENV_FILE = _env_path(
|
|
|
30
29
|
"OPENCLAW_SERVICE_ENV_FILE",
|
|
31
30
|
os.path.join(HOME, ".config", "systemd", "user", "openclaw-gateway.service.d", "env.conf"),
|
|
32
31
|
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def home() -> str:
|
|
36
|
-
return HOME
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def config_path() -> str:
|
|
40
|
-
return CONFIG
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def log_dir() -> str:
|
|
44
|
-
return LOG_DIR
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def sessions_base() -> str:
|
|
48
|
-
return SESSIONS_BASE
|