openclaw-diag-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +260 -0
- package/bin/ocdiag +14 -0
- package/bin/openclaw-diag.js +275 -0
- package/diag/01_sys_health.py +443 -0
- package/diag/02_environment.py +292 -0
- package/diag/03_configuration.py +131 -0
- package/diag/04_gateway.py +651 -0
- package/diag/05_recent_errors.py +246 -0
- package/diag/06_cron_jobs.py +694 -0
- package/diag/07_performance.py +687 -0
- package/diag/08_sessions.py +518 -0
- package/diag/09_plugin_diag.py +535 -0
- package/diag/10_shell_history.py +121 -0
- package/diag/__init__.py +0 -0
- package/lib/bundle.py +204 -0
- package/ocdiag/__init__.py +3 -0
- package/ocdiag/cli.py +39 -0
- package/ocdiag/dispatcher.py +137 -0
- package/ocdiag/jsonlog.py +65 -0
- package/ocdiag/output.py +131 -0
- package/ocdiag/paths.py +48 -0
- package/ocdiag/recent_logs.py +53 -0
- package/ocdiag/sensitive.py +41 -0
- package/ocdiag/timeutil.py +77 -0
- package/ocdiag/tokens.py +46 -0
- package/package.json +42 -0
- package/tools/__init__.py +0 -0
- package/tools/oc_session_extract.py +254 -0
- package/tools/oc_session_trace.py +715 -0
package/lib/bundle.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Produce a self-contained single-file diag script.
|
|
3
|
+
|
|
4
|
+
Reads `diag/<NN>_<name>.py`, inlines every `ocdiag/*.py` submodule into a
|
|
5
|
+
runtime bootstrap (registering them in `sys.modules` before the original
|
|
6
|
+
`from ocdiag import ...` statements run), and writes the result to stdout.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 lib/bundle.py gateway > standalone-gateway.py
|
|
10
|
+
python3 lib/bundle.py 04_gateway > standalone-gateway.py
|
|
11
|
+
|
|
12
|
+
The bundled script needs only Python 3.8+; no openclaw-diag-cli checkout.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
|
|
24
|
+
# ID → script filename, kept in sync with ocdiag/dispatcher.py:MODULES.
|
|
25
|
+
MODULES: List[tuple] = [
|
|
26
|
+
("sys_health", "diag/01_sys_health.py"),
|
|
27
|
+
("environment", "diag/02_environment.py"),
|
|
28
|
+
("configuration", "diag/03_configuration.py"),
|
|
29
|
+
("gateway", "diag/04_gateway.py"),
|
|
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
|
+
]
|
|
37
|
+
MODULE_BY_ID = {mid: rel for mid, rel in MODULES}
|
|
38
|
+
|
|
39
|
+
# Order matters: each submodule is exec'd into its own module object, and its
|
|
40
|
+
# `from . import X` / `from ocdiag.X import Y` statements run during exec —
|
|
41
|
+
# so dependencies must already be in sys.modules.
|
|
42
|
+
OCDIAG_SUBMODULE_ORDER = [
|
|
43
|
+
"paths",
|
|
44
|
+
"sensitive",
|
|
45
|
+
"timeutil",
|
|
46
|
+
"tokens",
|
|
47
|
+
"jsonlog",
|
|
48
|
+
"output",
|
|
49
|
+
"recent_logs",
|
|
50
|
+
"cli", # imports `from . import paths`
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_script(arg: str) -> Path:
|
|
55
|
+
if arg in MODULE_BY_ID:
|
|
56
|
+
return REPO_ROOT / MODULE_BY_ID[arg]
|
|
57
|
+
candidate = REPO_ROOT / "diag" / arg
|
|
58
|
+
if candidate.is_file():
|
|
59
|
+
return candidate
|
|
60
|
+
candidate_py = REPO_ROOT / "diag" / f"{arg}.py"
|
|
61
|
+
if candidate_py.is_file():
|
|
62
|
+
return candidate_py
|
|
63
|
+
raise SystemExit(
|
|
64
|
+
f"Error: unknown module id or script: {arg!r}. "
|
|
65
|
+
f"Use one of: {', '.join(mid for mid, _ in MODULES)}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def split_header(source: str) -> tuple:
|
|
70
|
+
"""Split a script into (shebang, docstring, body).
|
|
71
|
+
|
|
72
|
+
Recognises a single-line `#!/...` shebang and the first triple-quoted
|
|
73
|
+
docstring. Anything else becomes body.
|
|
74
|
+
"""
|
|
75
|
+
lines = source.split("\n")
|
|
76
|
+
i = 0
|
|
77
|
+
shebang = ""
|
|
78
|
+
if i < len(lines) and lines[i].startswith("#!"):
|
|
79
|
+
shebang = lines[i]
|
|
80
|
+
i += 1
|
|
81
|
+
# Skip blank lines / comments before docstring
|
|
82
|
+
while i < len(lines) and (lines[i].strip() == "" or lines[i].lstrip().startswith("#")):
|
|
83
|
+
i += 1
|
|
84
|
+
docstring_lines: List[str] = []
|
|
85
|
+
if i < len(lines) and (lines[i].lstrip().startswith('"""') or lines[i].lstrip().startswith("'''")):
|
|
86
|
+
quote = '"""' if '"""' in lines[i] else "'''"
|
|
87
|
+
first = lines[i]
|
|
88
|
+
docstring_lines.append(first)
|
|
89
|
+
# Single-line docstring?
|
|
90
|
+
if first.count(quote) >= 2:
|
|
91
|
+
i += 1
|
|
92
|
+
else:
|
|
93
|
+
i += 1
|
|
94
|
+
while i < len(lines):
|
|
95
|
+
docstring_lines.append(lines[i])
|
|
96
|
+
if quote in lines[i]:
|
|
97
|
+
i += 1
|
|
98
|
+
break
|
|
99
|
+
i += 1
|
|
100
|
+
body = "\n".join(lines[i:])
|
|
101
|
+
docstring = "\n".join(docstring_lines)
|
|
102
|
+
return shebang, docstring, body
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_path_hack(body: str) -> str:
|
|
106
|
+
"""Remove the `sys.path.insert(0, ...parent.parent)` line; the bundle
|
|
107
|
+
no longer needs to point at a checkout to find the `ocdiag` package."""
|
|
108
|
+
pattern = re.compile(
|
|
109
|
+
r"^sys\.path\.insert\(\s*0\s*,\s*str\(\s*Path\(__file__\)\.resolve\(\)\.parent\.parent\s*\)\s*\)\s*\n",
|
|
110
|
+
re.MULTILINE,
|
|
111
|
+
)
|
|
112
|
+
return pattern.sub("", body)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_future_imports(body: str) -> tuple:
|
|
116
|
+
"""Pull `from __future__ import ...` lines out of the body so we can put
|
|
117
|
+
them at the top of the bundled file (Python requires this)."""
|
|
118
|
+
pattern = re.compile(r"^from\s+__future__\s+import[^\n]*\n", re.MULTILINE)
|
|
119
|
+
futures = pattern.findall(body)
|
|
120
|
+
body_without = pattern.sub("", body)
|
|
121
|
+
return "".join(futures), body_without
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_ocdiag_sources() -> Dict[str, str]:
|
|
125
|
+
"""Read ocdiag/__init__.py and the submodule files we know we need."""
|
|
126
|
+
out: Dict[str, str] = {}
|
|
127
|
+
out["ocdiag"] = (REPO_ROOT / "ocdiag" / "__init__.py").read_text(encoding="utf-8")
|
|
128
|
+
for name in OCDIAG_SUBMODULE_ORDER:
|
|
129
|
+
out[f"ocdiag.{name}"] = (REPO_ROOT / "ocdiag" / f"{name}.py").read_text(encoding="utf-8")
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_bootstrap(sources: Dict[str, str]) -> str:
|
|
134
|
+
"""Render the runtime bootstrap that registers ocdiag modules."""
|
|
135
|
+
# Use repr() for embedding; Python repr of strings round-trips reliably.
|
|
136
|
+
parts = ["# ── Inlined ocdiag bootstrap (generated by lib/bundle.py) ──",
|
|
137
|
+
"import sys as _sys",
|
|
138
|
+
"import types as _types",
|
|
139
|
+
"_OCDIAG_SOURCES = {"]
|
|
140
|
+
parts.append(f" 'ocdiag': {sources['ocdiag']!r},")
|
|
141
|
+
for name in OCDIAG_SUBMODULE_ORDER:
|
|
142
|
+
full = f"ocdiag.{name}"
|
|
143
|
+
parts.append(f" {full!r}: {sources[full]!r},")
|
|
144
|
+
parts.append("}")
|
|
145
|
+
parts.append("")
|
|
146
|
+
parts.append("def _ocdiag_bootstrap():")
|
|
147
|
+
parts.append(" if 'ocdiag' in _sys.modules:")
|
|
148
|
+
parts.append(" return")
|
|
149
|
+
parts.append(" pkg = _types.ModuleType('ocdiag')")
|
|
150
|
+
parts.append(" pkg.__path__ = []")
|
|
151
|
+
parts.append(" pkg.__package__ = 'ocdiag'")
|
|
152
|
+
parts.append(" _sys.modules['ocdiag'] = pkg")
|
|
153
|
+
parts.append(" exec(compile(_OCDIAG_SOURCES['ocdiag'], '<ocdiag/__init__.py>', 'exec'), pkg.__dict__)")
|
|
154
|
+
parts.append(f" for _name in {OCDIAG_SUBMODULE_ORDER!r}:")
|
|
155
|
+
parts.append(" full = 'ocdiag.' + _name")
|
|
156
|
+
parts.append(" m = _types.ModuleType(full)")
|
|
157
|
+
parts.append(" m.__package__ = 'ocdiag'")
|
|
158
|
+
parts.append(" _sys.modules[full] = m")
|
|
159
|
+
parts.append(" setattr(pkg, _name, m)")
|
|
160
|
+
parts.append(" exec(compile(_OCDIAG_SOURCES[full], '<' + full + '>', 'exec'), m.__dict__)")
|
|
161
|
+
parts.append("")
|
|
162
|
+
parts.append("_ocdiag_bootstrap()")
|
|
163
|
+
parts.append("del _ocdiag_bootstrap")
|
|
164
|
+
parts.append("# ── End inlined ocdiag ──")
|
|
165
|
+
return "\n".join(parts)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_bundle(script_path: Path) -> str:
|
|
169
|
+
source = script_path.read_text(encoding="utf-8")
|
|
170
|
+
shebang, docstring, body = split_header(source)
|
|
171
|
+
if not shebang:
|
|
172
|
+
shebang = "#!/usr/bin/env python3"
|
|
173
|
+
body = strip_path_hack(body)
|
|
174
|
+
futures, body = extract_future_imports(body)
|
|
175
|
+
sources = read_ocdiag_sources()
|
|
176
|
+
bootstrap = render_bootstrap(sources)
|
|
177
|
+
header_comment = (
|
|
178
|
+
f"# Self-contained bundle of {script_path.relative_to(REPO_ROOT)} "
|
|
179
|
+
"(generated by openclaw-diag-cli/lib/bundle.py).\n"
|
|
180
|
+
"# Requires only Python 3.8+ stdlib. No openclaw-diag-cli checkout needed.\n"
|
|
181
|
+
)
|
|
182
|
+
chunks = [shebang]
|
|
183
|
+
if docstring:
|
|
184
|
+
chunks.append(docstring)
|
|
185
|
+
if futures:
|
|
186
|
+
chunks.append(futures.rstrip("\n"))
|
|
187
|
+
chunks.append(header_comment.rstrip("\n"))
|
|
188
|
+
chunks.append(bootstrap)
|
|
189
|
+
chunks.append(body.lstrip("\n"))
|
|
190
|
+
return "\n".join(chunks)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
194
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
195
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
196
|
+
sys.stdout.write(__doc__ or "")
|
|
197
|
+
return 0
|
|
198
|
+
script_path = resolve_script(argv[0])
|
|
199
|
+
sys.stdout.write(build_bundle(script_path))
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
sys.exit(main())
|
package/ocdiag/cli.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Common argparse setup for diag scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from . import paths
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_common_parser(description: str, prog: Optional[str] = None) -> argparse.ArgumentParser:
|
|
12
|
+
p = argparse.ArgumentParser(
|
|
13
|
+
prog=prog,
|
|
14
|
+
description=description,
|
|
15
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
16
|
+
)
|
|
17
|
+
p.add_argument(
|
|
18
|
+
"--config",
|
|
19
|
+
default=paths.CONFIG,
|
|
20
|
+
help="Path to openclaw.json",
|
|
21
|
+
)
|
|
22
|
+
p.add_argument(
|
|
23
|
+
"--log-dir",
|
|
24
|
+
default=paths.LOG_DIR,
|
|
25
|
+
help="Directory containing openclaw-*.log files",
|
|
26
|
+
)
|
|
27
|
+
p.add_argument(
|
|
28
|
+
"--sessions-base",
|
|
29
|
+
default=paths.SESSIONS_BASE,
|
|
30
|
+
help="Base directory containing per-agent session data",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"--openclaw-home",
|
|
34
|
+
default=paths.OPENCLAW_HOME,
|
|
35
|
+
help="OpenClaw home directory (~/.openclaw)",
|
|
36
|
+
)
|
|
37
|
+
p.add_argument("--json", action="store_true", help="Emit JSON output")
|
|
38
|
+
p.add_argument("--no-color", action="store_true", help="Disable colored output")
|
|
39
|
+
return p
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Dispatcher: list / run <name> / run all."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import runpy
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
|
|
16
|
+
# Module ID -> (label, script filename relative to REPO_ROOT)
|
|
17
|
+
MODULES = [
|
|
18
|
+
("sys_health", "系统健康检查", "diag/01_sys_health.py"),
|
|
19
|
+
("environment", "采集基础环境", "diag/02_environment.py"),
|
|
20
|
+
("configuration", "采集配置", "diag/03_configuration.py"),
|
|
21
|
+
("gateway", "采集 Gateway 状态", "diag/04_gateway.py"),
|
|
22
|
+
("recent_errors", "采集近期日志", "diag/05_recent_errors.py"),
|
|
23
|
+
("cron_jobs", "采集定时任务", "diag/06_cron_jobs.py"),
|
|
24
|
+
("performance", "采集模型与性能数据", "diag/07_performance.py"),
|
|
25
|
+
("sessions", "采集 Session 数据", "diag/08_sessions.py"),
|
|
26
|
+
("plugin_diag", "采集插件诊断", "diag/09_plugin_diag.py"),
|
|
27
|
+
("shell_history", "采集命令执行历史", "diag/10_shell_history.py"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
MODULE_BY_ID = {mid: (label, script) for mid, label, script in MODULES}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_list() -> int:
|
|
34
|
+
print("Available modules:")
|
|
35
|
+
for mid, label, _ in MODULES:
|
|
36
|
+
print(f" [x] {mid:<16s} {label}")
|
|
37
|
+
print()
|
|
38
|
+
print("Usage: ocdiag run <id> | ocdiag run all [--skip id1,id2] [--json]")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_script(script_rel: str, extra_args: List[str]) -> int:
|
|
43
|
+
script_path = REPO_ROOT / script_rel
|
|
44
|
+
if not script_path.is_file():
|
|
45
|
+
print(f"Error: script not found: {script_path}", file=sys.stderr)
|
|
46
|
+
return 2
|
|
47
|
+
saved_argv = sys.argv[:]
|
|
48
|
+
try:
|
|
49
|
+
sys.argv = [str(script_path), *extra_args]
|
|
50
|
+
runpy.run_path(str(script_path), run_name="__main__")
|
|
51
|
+
return 0
|
|
52
|
+
except SystemExit as e:
|
|
53
|
+
try:
|
|
54
|
+
return int(e.code) if e.code is not None else 0
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return 1
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f" ERROR: {script_path.name} crashed: {type(e).__name__}: {e}",
|
|
59
|
+
file=sys.stderr)
|
|
60
|
+
import traceback
|
|
61
|
+
traceback.print_exc(file=sys.stderr)
|
|
62
|
+
return 2
|
|
63
|
+
finally:
|
|
64
|
+
sys.argv = saved_argv
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_run(target: str, extra_args: List[str], skip_ids: List[str]) -> int:
|
|
68
|
+
json_mode = "--json" in extra_args
|
|
69
|
+
progress_stream = sys.stderr if json_mode else sys.stdout
|
|
70
|
+
if target == "all":
|
|
71
|
+
rc_overall = 0
|
|
72
|
+
total = sum(1 for mid, _, _ in MODULES if mid not in skip_ids)
|
|
73
|
+
n = 0
|
|
74
|
+
for mid, label, script in MODULES:
|
|
75
|
+
if mid in skip_ids:
|
|
76
|
+
continue
|
|
77
|
+
n += 1
|
|
78
|
+
print(f"\n[{n}/{total}] {label} ({mid})...", flush=True, file=progress_stream)
|
|
79
|
+
t0 = time.time()
|
|
80
|
+
rc = run_script(script, extra_args)
|
|
81
|
+
elapsed = time.time() - t0
|
|
82
|
+
print(f"[{n}/{total}] {label} ({mid}) ... done ({elapsed:.1f}s)", flush=True, file=progress_stream)
|
|
83
|
+
if rc != 0:
|
|
84
|
+
rc_overall = rc
|
|
85
|
+
return rc_overall
|
|
86
|
+
if target not in MODULE_BY_ID:
|
|
87
|
+
print(f"Error: unknown module '{target}'. Use `ocdiag list`.", file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
_, script = MODULE_BY_ID[target]
|
|
90
|
+
return run_script(script, extra_args)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv=None) -> int:
|
|
94
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
95
|
+
|
|
96
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
97
|
+
print("ocdiag — OpenClaw 诊断 CLI dispatcher")
|
|
98
|
+
print()
|
|
99
|
+
print("Usage:")
|
|
100
|
+
print(" ocdiag list 列出所有诊断模块")
|
|
101
|
+
print(" ocdiag run <id> 运行单个模块(id 或 all)")
|
|
102
|
+
print(" ocdiag run all [--skip ids] 运行全部模块,可跳过若干")
|
|
103
|
+
print()
|
|
104
|
+
print("--skip 后接逗号分隔的 module id 列表(如 performance,sessions)。")
|
|
105
|
+
print("其它参数(--config / --log-dir / --json / --no-color)原样传递。")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
cmd, rest = argv[0], argv[1:]
|
|
109
|
+
|
|
110
|
+
if cmd == "list":
|
|
111
|
+
return cmd_list()
|
|
112
|
+
|
|
113
|
+
if cmd == "run":
|
|
114
|
+
if not rest:
|
|
115
|
+
print("Error: run requires a target (module id or 'all').", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
target = rest[0]
|
|
118
|
+
sub = rest[1:]
|
|
119
|
+
skip_ids: List[str] = []
|
|
120
|
+
passthrough: List[str] = []
|
|
121
|
+
i = 0
|
|
122
|
+
while i < len(sub):
|
|
123
|
+
a = sub[i]
|
|
124
|
+
if a == "--skip" and i + 1 < len(sub):
|
|
125
|
+
skip_ids.extend(s.strip() for s in sub[i + 1].split(",") if s.strip())
|
|
126
|
+
i += 2
|
|
127
|
+
continue
|
|
128
|
+
passthrough.append(a)
|
|
129
|
+
i += 1
|
|
130
|
+
return cmd_run(target, passthrough, skip_ids)
|
|
131
|
+
|
|
132
|
+
print(f"Error: unknown command '{cmd}'", file=sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Helpers for OpenClaw JSON-formatted log entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_log_msg(obj: Dict[str, Any]) -> str:
|
|
10
|
+
"""Extract human-readable text from an OpenClaw JSON log entry."""
|
|
11
|
+
texts = []
|
|
12
|
+
for k in ("0", "1", "2", "3", "msg", "message"):
|
|
13
|
+
v = obj.get(k, "")
|
|
14
|
+
if not v or not isinstance(v, str):
|
|
15
|
+
continue
|
|
16
|
+
if v.startswith("{"):
|
|
17
|
+
try:
|
|
18
|
+
inner = json.loads(v)
|
|
19
|
+
if isinstance(inner, dict) and inner.get("subsystem"):
|
|
20
|
+
continue
|
|
21
|
+
if isinstance(inner, dict):
|
|
22
|
+
texts.append(" ".join(f"{ik}={iv}" for ik, iv in inner.items()))
|
|
23
|
+
else:
|
|
24
|
+
texts.append(v)
|
|
25
|
+
except (json.JSONDecodeError, AttributeError):
|
|
26
|
+
texts.append(v)
|
|
27
|
+
else:
|
|
28
|
+
texts.append(v)
|
|
29
|
+
return " | ".join(texts) if texts else ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_log_subsystem(obj: Dict[str, Any]) -> str:
|
|
33
|
+
"""Extract subsystem name from an OpenClaw JSON log entry."""
|
|
34
|
+
for k in ("0", "1", "2", "3"):
|
|
35
|
+
v = obj.get(k, "")
|
|
36
|
+
if v and isinstance(v, str) and v.startswith("{"):
|
|
37
|
+
try:
|
|
38
|
+
inner = json.loads(v)
|
|
39
|
+
if isinstance(inner, dict):
|
|
40
|
+
return inner.get("subsystem", "") or ""
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_name(obj: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
|
47
|
+
"""Return (plugin, subsystem) from _meta.name."""
|
|
48
|
+
meta = obj.get("_meta") or {}
|
|
49
|
+
name = meta.get("name", "") if isinstance(meta, dict) else ""
|
|
50
|
+
if not isinstance(name, str) or not name:
|
|
51
|
+
return None, None
|
|
52
|
+
try:
|
|
53
|
+
p = json.loads(name)
|
|
54
|
+
except Exception:
|
|
55
|
+
return None, None
|
|
56
|
+
if not isinstance(p, dict):
|
|
57
|
+
return None, None
|
|
58
|
+
return p.get("plugin"), p.get("subsystem")
|
|
59
|
+
|
|
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/output.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Output renderer: human-readable text with optional JSON mode.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
out = Output(module="gateway", json_mode=False)
|
|
5
|
+
out.section("模块 4:Gateway 状态")
|
|
6
|
+
out.item("端口 18789: 监听中")
|
|
7
|
+
out.evidence("ss -tlnp", "...")
|
|
8
|
+
out.set_data("port", 18789) # for JSON mode
|
|
9
|
+
out.done()
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, Dict, List, Optional, TextIO
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Output:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
module: str,
|
|
23
|
+
json_mode: bool = False,
|
|
24
|
+
no_color: bool = False,
|
|
25
|
+
stream: Optional[TextIO] = None,
|
|
26
|
+
):
|
|
27
|
+
self.module = module
|
|
28
|
+
self.json_mode = json_mode
|
|
29
|
+
self.no_color = no_color
|
|
30
|
+
self.stream = stream or sys.stdout
|
|
31
|
+
self._lines: List[str] = []
|
|
32
|
+
self._data: Dict[str, Any] = {}
|
|
33
|
+
self._status = "ok"
|
|
34
|
+
self._error_msg: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
# ── human-readable ──
|
|
37
|
+
def emit(self, text: str = "") -> None:
|
|
38
|
+
if not self.json_mode:
|
|
39
|
+
self._lines.append(text)
|
|
40
|
+
|
|
41
|
+
def section(self, title: str) -> None:
|
|
42
|
+
self.emit("")
|
|
43
|
+
self.emit(f"── {title} ──")
|
|
44
|
+
self.emit("")
|
|
45
|
+
|
|
46
|
+
def subsection(self, title: str) -> None:
|
|
47
|
+
self.emit("")
|
|
48
|
+
self.emit(f" ── {title} ──")
|
|
49
|
+
self.emit("")
|
|
50
|
+
|
|
51
|
+
def item(self, text: str) -> None:
|
|
52
|
+
self.emit(f" • {text}")
|
|
53
|
+
|
|
54
|
+
def line(self, text: str = "") -> None:
|
|
55
|
+
self.emit(text)
|
|
56
|
+
|
|
57
|
+
def evidence(self, source: str, data: str) -> None:
|
|
58
|
+
self.emit(f" [{source}]")
|
|
59
|
+
if data is None:
|
|
60
|
+
return
|
|
61
|
+
for raw in str(data).split("\n")[:100]:
|
|
62
|
+
self.emit(f" {raw}")
|
|
63
|
+
|
|
64
|
+
# ── JSON mode ──
|
|
65
|
+
def set_data(self, key: str, value: Any) -> None:
|
|
66
|
+
self._data[key] = value
|
|
67
|
+
|
|
68
|
+
def update_data(self, mapping: Dict[str, Any]) -> None:
|
|
69
|
+
self._data.update(mapping)
|
|
70
|
+
|
|
71
|
+
def add_data_item(self, key: str, value: Any) -> None:
|
|
72
|
+
if key not in self._data or not isinstance(self._data[key], list):
|
|
73
|
+
self._data[key] = []
|
|
74
|
+
self._data[key].append(value)
|
|
75
|
+
|
|
76
|
+
def fail(self, message: str) -> None:
|
|
77
|
+
self._status = "error"
|
|
78
|
+
self._error_msg = message
|
|
79
|
+
|
|
80
|
+
# ── finish ──
|
|
81
|
+
def done(self) -> int:
|
|
82
|
+
if self.json_mode:
|
|
83
|
+
payload: Dict[str, Any] = {
|
|
84
|
+
"module": self.module,
|
|
85
|
+
"status": self._status,
|
|
86
|
+
"data": self._data,
|
|
87
|
+
}
|
|
88
|
+
if self._error_msg:
|
|
89
|
+
payload["error"] = self._error_msg
|
|
90
|
+
self.stream.write(json.dumps(payload, ensure_ascii=False))
|
|
91
|
+
self.stream.write("\n")
|
|
92
|
+
else:
|
|
93
|
+
for ln in self._lines:
|
|
94
|
+
self.stream.write(ln + "\n")
|
|
95
|
+
try:
|
|
96
|
+
self.stream.flush()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return 0 if self._status == "ok" else 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Module-level convenience for scripts that just want functional API.
|
|
103
|
+
_active: Optional[Output] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def init(module: str, json_mode: bool = False, no_color: bool = False) -> Output:
|
|
107
|
+
global _active
|
|
108
|
+
_active = Output(module, json_mode=json_mode, no_color=no_color)
|
|
109
|
+
return _active
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def current() -> Output:
|
|
113
|
+
if _active is None:
|
|
114
|
+
raise RuntimeError("output.init() must be called first")
|
|
115
|
+
return _active
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def emit(text: str = "") -> None:
|
|
119
|
+
current().emit(text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def section(title: str) -> None:
|
|
123
|
+
current().section(title)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def item(text: str) -> None:
|
|
127
|
+
current().item(text)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def evidence(source: str, data: str) -> None:
|
|
131
|
+
current().evidence(source, data)
|
package/ocdiag/paths.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Filesystem paths used by the diag scripts. Override via environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _env_path(name: str, default: str) -> str:
|
|
10
|
+
v = os.environ.get(name)
|
|
11
|
+
return v if v else default
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
HOME = os.path.expanduser("~")
|
|
15
|
+
|
|
16
|
+
OPENCLAW_HOME = _env_path("OPENCLAW_HOME", os.path.join(HOME, ".openclaw"))
|
|
17
|
+
CONFIG = _env_path("OPENCLAW_CONFIG", os.path.join(OPENCLAW_HOME, "openclaw.json"))
|
|
18
|
+
CRON_JOBS = _env_path("OPENCLAW_CRON_JOBS", os.path.join(OPENCLAW_HOME, "cron", "jobs.json"))
|
|
19
|
+
CRON_STATE = _env_path("OPENCLAW_CRON_STATE", os.path.join(OPENCLAW_HOME, "cron", "jobs-state.json"))
|
|
20
|
+
CRON_RUNS_DIR = _env_path("OPENCLAW_CRON_RUNS", os.path.join(OPENCLAW_HOME, "cron", "runs"))
|
|
21
|
+
SESSIONS_BASE = _env_path("OPENCLAW_SESSIONS", os.path.join(OPENCLAW_HOME, "agents"))
|
|
22
|
+
EXTENSIONS_DIR = _env_path("OPENCLAW_EXTENSIONS", os.path.join(OPENCLAW_HOME, "extensions"))
|
|
23
|
+
|
|
24
|
+
LOG_DIR = _env_path("OPENCLAW_LOG_DIR", "/tmp/openclaw")
|
|
25
|
+
SERVICE_FILE = _env_path(
|
|
26
|
+
"OPENCLAW_SERVICE_FILE",
|
|
27
|
+
os.path.join(HOME, ".config", "systemd", "user", "openclaw-gateway.service"),
|
|
28
|
+
)
|
|
29
|
+
SERVICE_ENV_FILE = _env_path(
|
|
30
|
+
"OPENCLAW_SERVICE_ENV_FILE",
|
|
31
|
+
os.path.join(HOME, ".config", "systemd", "user", "openclaw-gateway.service.d", "env.conf"),
|
|
32
|
+
)
|
|
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
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Discover today's openclaw log files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _today_start_epoch() -> float:
|
|
13
|
+
today = datetime.now().date()
|
|
14
|
+
return time.mktime(today.timetuple())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discover_recent_logs(log_dir: str) -> List[str]:
|
|
18
|
+
"""Return log files whose mtime >= today 00:00, sorted newest first."""
|
|
19
|
+
pattern = os.path.join(log_dir, "openclaw-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].log")
|
|
20
|
+
matched: List[tuple] = []
|
|
21
|
+
cutoff = _today_start_epoch()
|
|
22
|
+
for f in glob.glob(pattern):
|
|
23
|
+
if not os.path.isfile(f):
|
|
24
|
+
continue
|
|
25
|
+
try:
|
|
26
|
+
m = os.path.getmtime(f)
|
|
27
|
+
except OSError:
|
|
28
|
+
continue
|
|
29
|
+
if m >= cutoff:
|
|
30
|
+
matched.append((m, f))
|
|
31
|
+
matched.sort(reverse=True)
|
|
32
|
+
return [p for _, p in matched]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def latest_app_log(log_dir: str) -> Optional[str]:
|
|
36
|
+
pattern = os.path.join(log_dir, "openclaw-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].log")
|
|
37
|
+
matched: List[tuple] = []
|
|
38
|
+
for f in glob.glob(pattern):
|
|
39
|
+
try:
|
|
40
|
+
matched.append((os.path.getmtime(f), f))
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
|
43
|
+
if not matched:
|
|
44
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
45
|
+
candidate = os.path.join(log_dir, f"openclaw-{today}.log")
|
|
46
|
+
return candidate if os.path.isfile(candidate) else None
|
|
47
|
+
matched.sort(reverse=True)
|
|
48
|
+
return matched[0][1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def all_logs(log_dir: str) -> List[str]:
|
|
52
|
+
pattern = os.path.join(log_dir, "openclaw-*.log")
|
|
53
|
+
return sorted(glob.glob(pattern))
|