nexo-brain 5.3.28 → 5.4.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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/bin/nexo-brain.js +24 -7
- package/package.json +1 -1
- package/src/auto_update.py +37 -16
- package/src/calibration_migration.py +242 -0
- package/src/cli.py +183 -0
- package/src/desktop_bridge.py +459 -0
- package/src/events_bus.py +155 -0
- package/src/health_check.py +195 -0
- package/src/plugin_loader.py +5 -0
- package/src/plugins/update.py +5 -4
- package/src/scripts/nexo-cron-wrapper.sh +78 -22
- package/src/scripts/nexo-update.sh +14 -288
- package/src/server.py +140 -99
- package/src/tree_hygiene.py +56 -0
- package/src/user_context.py +23 -5
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Runtime events bus — append-only NDJSON stream at ~/.nexo/runtime/events.ndjson.
|
|
2
|
+
|
|
3
|
+
NEXO Brain writes events that external UIs (NEXO Desktop, mobile, web)
|
|
4
|
+
can tail for real-time attention signals, proactive messages, health
|
|
5
|
+
alerts, and general notifications.
|
|
6
|
+
|
|
7
|
+
Contract:
|
|
8
|
+
- One JSON object per line. No multi-line JSON.
|
|
9
|
+
- Monotonic `id` (integer) and `ts` (unix seconds, float).
|
|
10
|
+
- Stable event envelope keys: id, ts, type, priority, text, reason,
|
|
11
|
+
source, extra. Unknown keys are preserved.
|
|
12
|
+
- File is append-only. Rotation happens at 5 MB: current file is
|
|
13
|
+
renamed to events-<ts>.ndjson and a fresh empty file is created.
|
|
14
|
+
- Readers tail the current file; rotation is transparent because the
|
|
15
|
+
file is reopened after rename detection.
|
|
16
|
+
|
|
17
|
+
Event types (stable):
|
|
18
|
+
attention_required — user should look at something
|
|
19
|
+
proactive_message — Brain wants to initiate dialogue
|
|
20
|
+
followup_alert — overdue or urgent followup
|
|
21
|
+
health_alert — a core system is degraded
|
|
22
|
+
info — general update, no attention needed
|
|
23
|
+
|
|
24
|
+
Priorities: "low" | "normal" | "high" | "urgent"
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import fcntl
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
EVENT_TYPES = {
|
|
36
|
+
"attention_required",
|
|
37
|
+
"proactive_message",
|
|
38
|
+
"followup_alert",
|
|
39
|
+
"health_alert",
|
|
40
|
+
"info",
|
|
41
|
+
}
|
|
42
|
+
PRIORITIES = {"low", "normal", "high", "urgent"}
|
|
43
|
+
ROTATION_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _nexo_home() -> Path:
|
|
47
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def events_path() -> Path:
|
|
51
|
+
return _nexo_home() / "runtime" / "events.ndjson"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _next_id(path: Path) -> int:
|
|
55
|
+
"""Return the next monotonic id by reading the last line's id, or 1."""
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
return 1
|
|
58
|
+
try:
|
|
59
|
+
# Read last 4 KB — more than enough for the tail line
|
|
60
|
+
with path.open("rb") as fh:
|
|
61
|
+
fh.seek(0, os.SEEK_END)
|
|
62
|
+
size = fh.tell()
|
|
63
|
+
fh.seek(max(0, size - 4096))
|
|
64
|
+
tail = fh.read().decode("utf-8", errors="ignore")
|
|
65
|
+
lines = [ln for ln in tail.splitlines() if ln.strip()]
|
|
66
|
+
if not lines:
|
|
67
|
+
return 1
|
|
68
|
+
last = json.loads(lines[-1])
|
|
69
|
+
return int(last.get("id", 0)) + 1
|
|
70
|
+
except Exception:
|
|
71
|
+
return int(time.time())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _rotate_if_needed(path: Path) -> None:
|
|
75
|
+
try:
|
|
76
|
+
if path.is_file() and path.stat().st_size > ROTATION_BYTES:
|
|
77
|
+
stamp = int(time.time())
|
|
78
|
+
rotated = path.with_name(f"events-{stamp}.ndjson")
|
|
79
|
+
path.rename(rotated)
|
|
80
|
+
except Exception:
|
|
81
|
+
# Rotation failure is non-fatal; worst case the file grows
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def emit(
|
|
86
|
+
event_type: str,
|
|
87
|
+
*,
|
|
88
|
+
text: str = "",
|
|
89
|
+
reason: str = "",
|
|
90
|
+
priority: str = "normal",
|
|
91
|
+
source: str = "nexo-brain",
|
|
92
|
+
extra: dict[str, Any] | None = None,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Append a new event to the bus. Returns the full event dict."""
|
|
95
|
+
if event_type not in EVENT_TYPES:
|
|
96
|
+
raise ValueError(f"unknown event_type: {event_type}")
|
|
97
|
+
if priority not in PRIORITIES:
|
|
98
|
+
raise ValueError(f"unknown priority: {priority}")
|
|
99
|
+
|
|
100
|
+
path = events_path()
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
_rotate_if_needed(path)
|
|
103
|
+
|
|
104
|
+
event = {
|
|
105
|
+
"id": _next_id(path),
|
|
106
|
+
"ts": time.time(),
|
|
107
|
+
"type": event_type,
|
|
108
|
+
"priority": priority,
|
|
109
|
+
"text": text,
|
|
110
|
+
"reason": reason,
|
|
111
|
+
"source": source,
|
|
112
|
+
"extra": extra or {},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
line = json.dumps(event, ensure_ascii=False) + "\n"
|
|
116
|
+
# fcntl flock for cross-process safety on macOS/Linux
|
|
117
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
118
|
+
try:
|
|
119
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
120
|
+
fh.write(line)
|
|
121
|
+
fh.flush()
|
|
122
|
+
finally:
|
|
123
|
+
try:
|
|
124
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return event
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def tail(lines: int = 50, since_id: int | None = None) -> list[dict]:
|
|
132
|
+
"""Return the most recent events, newest last. Optionally filter by id."""
|
|
133
|
+
path = events_path()
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
return []
|
|
136
|
+
try:
|
|
137
|
+
with path.open("r", encoding="utf-8", errors="ignore") as fh:
|
|
138
|
+
raw = fh.readlines()
|
|
139
|
+
except Exception:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
events: list[dict] = []
|
|
143
|
+
for ln in raw[-max(lines, 1) * 4:]: # generous buffer for malformed lines
|
|
144
|
+
ln = ln.strip()
|
|
145
|
+
if not ln:
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
evt = json.loads(ln)
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
if since_id is not None and int(evt.get("id", 0)) <= since_id:
|
|
152
|
+
continue
|
|
153
|
+
events.append(evt)
|
|
154
|
+
|
|
155
|
+
return events[-lines:]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Health check — one-shot snapshot of NEXO Brain subsystems.
|
|
2
|
+
|
|
3
|
+
Output is stable JSON consumable by any UI or monitoring tool.
|
|
4
|
+
No side effects, no network, no mutation.
|
|
5
|
+
|
|
6
|
+
Subsystems reported:
|
|
7
|
+
- runtime : NEXO_HOME exists, version.json readable, version string
|
|
8
|
+
- database : SQLite reachable, integrity check, basic row counts
|
|
9
|
+
- crons : count of active personal LaunchAgents (macOS) or unknown
|
|
10
|
+
- mcp : Claude Code MCP config present and mentions nexo-brain
|
|
11
|
+
- errors : count of recent errors in ~/.nexo/operations/*.log (24h)
|
|
12
|
+
- events : count of events emitted in last 24h
|
|
13
|
+
|
|
14
|
+
Top-level `status` is "ok" | "degraded" | "error".
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _nexo_home() -> Path:
|
|
29
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_runtime() -> dict:
|
|
33
|
+
home = _nexo_home()
|
|
34
|
+
ver_file = home / "version.json"
|
|
35
|
+
out: dict[str, Any] = {"nexo_home": str(home), "exists": home.is_dir()}
|
|
36
|
+
if ver_file.is_file():
|
|
37
|
+
try:
|
|
38
|
+
payload = json.loads(ver_file.read_text())
|
|
39
|
+
out["version"] = payload.get("version", "unknown")
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
out["version"] = "unreadable"
|
|
42
|
+
out["error"] = str(exc)
|
|
43
|
+
else:
|
|
44
|
+
out["version"] = "missing"
|
|
45
|
+
out["status"] = "ok" if out["exists"] and out.get("version") not in ("missing", "unreadable") else "degraded"
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_database() -> dict:
|
|
50
|
+
db_path = _nexo_home() / "data" / "nexo.db"
|
|
51
|
+
out: dict[str, Any] = {"path": str(db_path), "exists": db_path.is_file()}
|
|
52
|
+
if not out["exists"]:
|
|
53
|
+
out["status"] = "error"
|
|
54
|
+
return out
|
|
55
|
+
try:
|
|
56
|
+
conn = sqlite3.connect(str(db_path), timeout=2.0)
|
|
57
|
+
try:
|
|
58
|
+
cur = conn.execute("PRAGMA integrity_check")
|
|
59
|
+
row = cur.fetchone()
|
|
60
|
+
out["integrity"] = row[0] if row else "unknown"
|
|
61
|
+
finally:
|
|
62
|
+
conn.close()
|
|
63
|
+
out["status"] = "ok" if out["integrity"] == "ok" else "degraded"
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
out["status"] = "error"
|
|
66
|
+
out["error"] = str(exc)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_crons() -> dict:
|
|
71
|
+
out: dict[str, Any] = {}
|
|
72
|
+
# macOS LaunchAgents
|
|
73
|
+
agents_dir = Path.home() / "Library" / "LaunchAgents"
|
|
74
|
+
if agents_dir.is_dir():
|
|
75
|
+
try:
|
|
76
|
+
plists = [p for p in agents_dir.glob("com.nexo.*.plist")]
|
|
77
|
+
out["launch_agents"] = len(plists)
|
|
78
|
+
out["platform"] = "macos"
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
out["error"] = str(exc)
|
|
81
|
+
else:
|
|
82
|
+
out["platform"] = "unknown"
|
|
83
|
+
out["status"] = "ok"
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_mcp() -> dict:
|
|
88
|
+
out: dict[str, Any] = {}
|
|
89
|
+
candidates = [
|
|
90
|
+
Path.home() / ".claude.json",
|
|
91
|
+
Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
|
|
92
|
+
]
|
|
93
|
+
found = []
|
|
94
|
+
for path in candidates:
|
|
95
|
+
if not path.is_file():
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
text = path.read_text(errors="ignore")
|
|
99
|
+
except Exception:
|
|
100
|
+
continue
|
|
101
|
+
if "nexo" in text.lower():
|
|
102
|
+
found.append(str(path))
|
|
103
|
+
out["configs_with_nexo"] = found
|
|
104
|
+
out["status"] = "ok" if found else "degraded"
|
|
105
|
+
if not found:
|
|
106
|
+
out["reason"] = "no client config mentions nexo-brain"
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_errors(hours: int = 24) -> dict:
|
|
111
|
+
ops_dir = _nexo_home() / "operations"
|
|
112
|
+
out: dict[str, Any] = {"dir": str(ops_dir)}
|
|
113
|
+
if not ops_dir.is_dir():
|
|
114
|
+
out["recent_errors"] = 0
|
|
115
|
+
out["status"] = "ok"
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
cutoff = time.time() - hours * 3600
|
|
119
|
+
recent = 0
|
|
120
|
+
sample: list[str] = []
|
|
121
|
+
error_re = re.compile(r"(?i)\b(error|traceback|exception|fail(ed)?)\b")
|
|
122
|
+
|
|
123
|
+
for log in ops_dir.glob("*.log"):
|
|
124
|
+
try:
|
|
125
|
+
if log.stat().st_mtime < cutoff:
|
|
126
|
+
continue
|
|
127
|
+
with log.open("r", errors="ignore") as fh:
|
|
128
|
+
for line in fh:
|
|
129
|
+
if error_re.search(line):
|
|
130
|
+
recent += 1
|
|
131
|
+
if len(sample) < 5:
|
|
132
|
+
sample.append(line.strip()[:200])
|
|
133
|
+
except Exception:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
out["recent_errors"] = recent
|
|
137
|
+
out["sample"] = sample
|
|
138
|
+
out["status"] = "ok" if recent < 20 else "degraded"
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_events(hours: int = 24) -> dict:
|
|
143
|
+
events_file = _nexo_home() / "runtime" / "events.ndjson"
|
|
144
|
+
out: dict[str, Any] = {"path": str(events_file), "exists": events_file.is_file()}
|
|
145
|
+
if not events_file.is_file():
|
|
146
|
+
out["recent_events"] = 0
|
|
147
|
+
out["status"] = "ok"
|
|
148
|
+
return out
|
|
149
|
+
cutoff = time.time() - hours * 3600
|
|
150
|
+
count = 0
|
|
151
|
+
urgent = 0
|
|
152
|
+
try:
|
|
153
|
+
with events_file.open("r", errors="ignore") as fh:
|
|
154
|
+
for line in fh:
|
|
155
|
+
line = line.strip()
|
|
156
|
+
if not line:
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
evt = json.loads(line)
|
|
160
|
+
except Exception:
|
|
161
|
+
continue
|
|
162
|
+
if float(evt.get("ts", 0)) < cutoff:
|
|
163
|
+
continue
|
|
164
|
+
count += 1
|
|
165
|
+
if evt.get("priority") == "urgent":
|
|
166
|
+
urgent += 1
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
out["error"] = str(exc)
|
|
169
|
+
out["recent_events"] = count
|
|
170
|
+
out["urgent"] = urgent
|
|
171
|
+
out["status"] = "degraded" if urgent > 0 else "ok"
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def collect() -> dict:
|
|
176
|
+
"""Run every subsystem check and return a unified report."""
|
|
177
|
+
report: dict[str, Any] = {
|
|
178
|
+
"ts": time.time(),
|
|
179
|
+
"subsystems": {
|
|
180
|
+
"runtime": _check_runtime(),
|
|
181
|
+
"database": _check_database(),
|
|
182
|
+
"crons": _check_crons(),
|
|
183
|
+
"mcp": _check_mcp(),
|
|
184
|
+
"errors": _check_errors(),
|
|
185
|
+
"events": _check_events(),
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
statuses = [sub.get("status", "unknown") for sub in report["subsystems"].values()]
|
|
189
|
+
if "error" in statuses:
|
|
190
|
+
report["status"] = "error"
|
|
191
|
+
elif "degraded" in statuses:
|
|
192
|
+
report["status"] = "degraded"
|
|
193
|
+
else:
|
|
194
|
+
report["status"] = "ok"
|
|
195
|
+
return report
|
package/src/plugin_loader.py
CHANGED
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
|
|
11
11
|
from db import get_db
|
|
12
12
|
from fastmcp.tools import Tool
|
|
13
|
+
from tree_hygiene import is_duplicate_artifact_name
|
|
13
14
|
|
|
14
15
|
SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
16
|
PLUGINS_DIR = os.path.join(SERVER_DIR, "plugins")
|
|
@@ -47,12 +48,16 @@ def load_all_plugins(mcp) -> int:
|
|
|
47
48
|
if os.path.isdir(PLUGINS_DIR):
|
|
48
49
|
for f in sorted(os.listdir(PLUGINS_DIR)):
|
|
49
50
|
if f.endswith(".py") and f != "__init__.py":
|
|
51
|
+
if is_duplicate_artifact_name(os.path.join(PLUGINS_DIR, f)):
|
|
52
|
+
continue
|
|
50
53
|
plugin_map[f] = (PLUGINS_DIR, "repo")
|
|
51
54
|
|
|
52
55
|
# 2. Personal plugins (override if same filename)
|
|
53
56
|
if os.path.isdir(PERSONAL_PLUGINS_DIR):
|
|
54
57
|
for f in sorted(os.listdir(PERSONAL_PLUGINS_DIR)):
|
|
55
58
|
if f.endswith(".py") and f != "__init__.py":
|
|
59
|
+
if is_duplicate_artifact_name(os.path.join(PERSONAL_PLUGINS_DIR, f)):
|
|
60
|
+
continue
|
|
56
61
|
source = "personal (override)" if f in plugin_map else "personal"
|
|
57
62
|
plugin_map[f] = (PERSONAL_PLUGINS_DIR, source)
|
|
58
63
|
|
package/src/plugins/update.py
CHANGED
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from runtime_home import export_resolved_nexo_home
|
|
13
|
+
from tree_hygiene import is_duplicate_artifact_name
|
|
13
14
|
|
|
14
15
|
# Code root is the parent of plugins/:
|
|
15
16
|
# - source checkout: <repo>/src
|
|
@@ -100,7 +101,7 @@ def _refresh_installed_manifest():
|
|
|
100
101
|
if src_crons.exists():
|
|
101
102
|
dst_crons.mkdir(parents=True, exist_ok=True)
|
|
102
103
|
for f in src_crons.iterdir():
|
|
103
|
-
if f.is_file():
|
|
104
|
+
if f.is_file() and not is_duplicate_artifact_name(f):
|
|
104
105
|
dest = dst_crons / f.name
|
|
105
106
|
if _paths_match(f, dest):
|
|
106
107
|
continue
|
|
@@ -111,11 +112,11 @@ def _refresh_installed_manifest():
|
|
|
111
112
|
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
112
113
|
"script_names": sorted(
|
|
113
114
|
f.name for f in (artifact_src / "scripts").iterdir()
|
|
114
|
-
if f.is_file()
|
|
115
|
+
if f.is_file() and not is_duplicate_artifact_name(f)
|
|
115
116
|
) if (artifact_src / "scripts").is_dir() else [],
|
|
116
117
|
"hook_names": sorted(
|
|
117
118
|
f.name for f in (artifact_src / "hooks").iterdir()
|
|
118
|
-
if f.is_file()
|
|
119
|
+
if f.is_file() and not is_duplicate_artifact_name(f)
|
|
119
120
|
) if (artifact_src / "hooks").is_dir() else [],
|
|
120
121
|
}
|
|
121
122
|
(config_dir / "runtime-core-artifacts.json").write_text(
|
|
@@ -368,7 +369,7 @@ def _sync_hooks_to_home():
|
|
|
368
369
|
hooks_dest.mkdir(parents=True, exist_ok=True)
|
|
369
370
|
synced = 0
|
|
370
371
|
for f in hooks_src.iterdir():
|
|
371
|
-
if f.is_file() and f.suffix == ".sh":
|
|
372
|
+
if f.is_file() and f.suffix == ".sh" and not is_duplicate_artifact_name(f):
|
|
372
373
|
dest = hooks_dest / f.name
|
|
373
374
|
if not _paths_match(f, dest):
|
|
374
375
|
shutil.copy2(str(f), str(dest))
|
|
@@ -13,6 +13,7 @@ shift
|
|
|
13
13
|
|
|
14
14
|
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
15
15
|
DB="$NEXO_HOME/data/nexo.db"
|
|
16
|
+
SPOOL_DIR="$NEXO_HOME/operations/cron-spool"
|
|
16
17
|
|
|
17
18
|
# Unlock macOS Keychain so headless Claude Code can read auth tokens.
|
|
18
19
|
# Claude Code stores its API key in the login keychain which auto-locks.
|
|
@@ -21,40 +22,95 @@ if [ -f "$KEYCHAIN_PASS_FILE" ] && [ "$(uname)" = "Darwin" ]; then
|
|
|
21
22
|
security unlock-keychain -p "$(cat "$KEYCHAIN_PASS_FILE")" ~/Library/Keychains/login.keychain-db 2>/dev/null || true
|
|
22
23
|
fi
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
START_EPOCH=$(python3 - <<'PY'
|
|
26
|
+
import time
|
|
27
|
+
print(f"{time.time():.6f}")
|
|
28
|
+
PY
|
|
29
|
+
)
|
|
30
|
+
STARTED_AT=$(python3 - <<'PY'
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
|
|
33
|
+
PY
|
|
34
|
+
)
|
|
31
35
|
|
|
32
36
|
# Run the actual command, capture output
|
|
33
37
|
OUTPUT_FILE=$(mktemp)
|
|
38
|
+
trap 'rm -f "$OUTPUT_FILE"' EXIT
|
|
34
39
|
"$@" > "$OUTPUT_FILE" 2>&1
|
|
35
40
|
EXIT_CODE=$?
|
|
41
|
+
ENDED_AT=$(python3 - <<'PY'
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
|
|
44
|
+
PY
|
|
45
|
+
)
|
|
46
|
+
DURATION_SECS=$(python3 - <<PY
|
|
47
|
+
start = float("$START_EPOCH")
|
|
48
|
+
import time
|
|
49
|
+
print(round(time.time() - start, 1))
|
|
50
|
+
PY
|
|
51
|
+
)
|
|
36
52
|
|
|
37
53
|
# Extract summary (last meaningful line, max 500 chars)
|
|
38
|
-
SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500
|
|
54
|
+
SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500)
|
|
39
55
|
|
|
40
56
|
# Extract error if failed
|
|
41
57
|
ERROR=""
|
|
42
58
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
43
|
-
ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500
|
|
59
|
+
ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500)
|
|
44
60
|
fi
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
if ! python3 - "$DB" "$CRON_ID" "$STARTED_AT" "$ENDED_AT" "$EXIT_CODE" "$SUMMARY" "$ERROR" "$DURATION_SECS" <<'PY'
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import sqlite3
|
|
66
|
+
import sys
|
|
67
|
+
|
|
68
|
+
db_path, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
|
|
69
|
+
conn = sqlite3.connect(db_path)
|
|
70
|
+
try:
|
|
71
|
+
conn.execute(
|
|
72
|
+
"""
|
|
73
|
+
INSERT INTO cron_runs (
|
|
74
|
+
cron_id, started_at, ended_at, exit_code, summary, error, duration_secs
|
|
75
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
""",
|
|
77
|
+
(
|
|
78
|
+
cron_id,
|
|
79
|
+
started_at,
|
|
80
|
+
ended_at,
|
|
81
|
+
int(exit_code),
|
|
82
|
+
summary,
|
|
83
|
+
error,
|
|
84
|
+
float(duration_secs),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
conn.commit()
|
|
88
|
+
finally:
|
|
89
|
+
conn.close()
|
|
90
|
+
PY
|
|
91
|
+
then
|
|
92
|
+
mkdir -p "$SPOOL_DIR"
|
|
93
|
+
SPOOL_FILE="$SPOOL_DIR/${CRON_ID}-$(date +%Y%m%d-%H%M%S)-$$.json"
|
|
94
|
+
python3 - "$SPOOL_FILE" "$CRON_ID" "$STARTED_AT" "$ENDED_AT" "$EXIT_CODE" "$SUMMARY" "$ERROR" "$DURATION_SECS" <<'PY'
|
|
95
|
+
from __future__ import annotations
|
|
96
|
+
|
|
97
|
+
import json
|
|
98
|
+
import sys
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
spool_file, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
|
|
102
|
+
payload = {
|
|
103
|
+
"cron_id": cron_id,
|
|
104
|
+
"started_at": started_at,
|
|
105
|
+
"ended_at": ended_at,
|
|
106
|
+
"exit_code": int(exit_code),
|
|
107
|
+
"summary": summary,
|
|
108
|
+
"error": error,
|
|
109
|
+
"duration_secs": float(duration_secs),
|
|
110
|
+
}
|
|
111
|
+
Path(spool_file).write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
112
|
+
PY
|
|
113
|
+
echo "[nexo-cron-wrapper] DB write failed; spooled run to $SPOOL_FILE" >&2
|
|
114
|
+
fi
|
|
59
115
|
|
|
60
116
|
exit $EXIT_CODE
|