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/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())
@@ -0,0 +1,3 @@
1
+ """ocdiag — shared library for openclaw-diag-cli scripts."""
2
+
3
+ __version__ = "0.1.0"
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 ""
@@ -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)
@@ -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))