@misterhuydo/sentinel 1.5.53 → 1.5.54
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/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-21T05:
|
|
1
|
+
2026-04-21T05:59:33.396Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-04-21T06:02:37.029Z",
|
|
3
|
+
"checkpoint_at": "2026-04-21T06:02:37.031Z",
|
|
4
4
|
"active_files": [
|
|
5
5
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
|
|
6
6
|
"J:\\Projects\\Sentinel\\cli\\lib\\test.js",
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.5.
|
|
1
|
+
__version__ = "1.5.54"
|
package/python/sentinel/main.py
CHANGED
|
@@ -1730,25 +1730,71 @@ async def _patch_soak_monitor(cfg_loader: ConfigLoader) -> None:
|
|
|
1730
1730
|
logger.info("Patch soak complete — hash=%s clean=True auto_publish=%s", patch_hash[:8], auto_publish)
|
|
1731
1731
|
|
|
1732
1732
|
|
|
1733
|
+
def _format_monitor_step_output(tool: str, raw: str) -> str | None:
|
|
1734
|
+
"""
|
|
1735
|
+
Convert a _run_tool JSON result into a human-readable Slack string.
|
|
1736
|
+
Returns None if there are no meaningful results to post (e.g. empty filter match).
|
|
1737
|
+
"""
|
|
1738
|
+
import json as _json
|
|
1739
|
+
try:
|
|
1740
|
+
data = _json.loads(raw)
|
|
1741
|
+
except Exception:
|
|
1742
|
+
return raw.strip() or None
|
|
1743
|
+
|
|
1744
|
+
# ── fetch_logs ────────────────────────────────────────────────────────────
|
|
1745
|
+
if tool == "fetch_logs":
|
|
1746
|
+
results = data.get("results", [])
|
|
1747
|
+
all_lines: list[str] = []
|
|
1748
|
+
for r in results:
|
|
1749
|
+
for line in (r.get("lines") or []):
|
|
1750
|
+
all_lines.append(line.strip())
|
|
1751
|
+
if not all_lines:
|
|
1752
|
+
return None # no matches — skip posting this cycle
|
|
1753
|
+
lines_text = "\n".join(all_lines[:200])
|
|
1754
|
+
if len(all_lines) > 200:
|
|
1755
|
+
lines_text += f"\n_…and {len(all_lines) - 200} more lines_"
|
|
1756
|
+
return f"```\n{lines_text}\n```"
|
|
1757
|
+
|
|
1758
|
+
# ── filter_logs ───────────────────────────────────────────────────────────
|
|
1759
|
+
if tool == "filter_logs":
|
|
1760
|
+
matches = data.get("matches") or data.get("results") or []
|
|
1761
|
+
if not matches:
|
|
1762
|
+
return None
|
|
1763
|
+
lines = [
|
|
1764
|
+
f"[{m.get('source','?')}:{m.get('file','?')}] {m.get('line', m)}"
|
|
1765
|
+
if isinstance(m, dict) else str(m)
|
|
1766
|
+
for m in matches[:200]
|
|
1767
|
+
]
|
|
1768
|
+
text = "\n".join(lines)
|
|
1769
|
+
if len(matches) > 200:
|
|
1770
|
+
text += f"\n_…and {len(matches) - 200} more_"
|
|
1771
|
+
return f"```\n{text}\n```"
|
|
1772
|
+
|
|
1773
|
+
# ── get_status / check_health / others — always show ─────────────────────
|
|
1774
|
+
if "error" in data:
|
|
1775
|
+
return f":warning: `{tool}` error: {data['error']}"
|
|
1776
|
+
# Generic: strip JSON, show as compact text
|
|
1777
|
+
return raw.strip()
|
|
1778
|
+
|
|
1779
|
+
|
|
1733
1780
|
async def _execute_monitor(monitor: dict, cfg_loader: ConfigLoader, store: StateStore) -> None:
|
|
1734
|
-
"""Execute one monitor run: call all steps, post
|
|
1781
|
+
"""Execute one monitor run: call all steps, post formatted output to Slack."""
|
|
1735
1782
|
import json as _json
|
|
1736
1783
|
from datetime import datetime, timezone, timedelta
|
|
1737
1784
|
from .sentinel_boss import _run_tool, _format_duration
|
|
1738
1785
|
|
|
1739
|
-
mon_id
|
|
1740
|
-
channel
|
|
1741
|
-
user_id
|
|
1742
|
-
steps
|
|
1743
|
-
interval_s
|
|
1744
|
-
stop_at
|
|
1745
|
-
max_runs
|
|
1746
|
-
runs_so_far
|
|
1747
|
-
mon_name
|
|
1786
|
+
mon_id = monitor["id"]
|
|
1787
|
+
channel = monitor.get("channel", "")
|
|
1788
|
+
user_id = monitor.get("user_id", "")
|
|
1789
|
+
steps = _json.loads(monitor.get("steps_json") or "[]")
|
|
1790
|
+
interval_s = int(monitor.get("interval_seconds", 300))
|
|
1791
|
+
stop_at = monitor.get("stop_at")
|
|
1792
|
+
max_runs = monitor.get("max_runs")
|
|
1793
|
+
runs_so_far = int(monitor.get("runs_so_far") or 0)
|
|
1794
|
+
mon_name = monitor.get("name") or " → ".join(s.get("tool", "") for s in steps)
|
|
1748
1795
|
|
|
1749
1796
|
cfg = cfg_loader.sentinel
|
|
1750
1797
|
|
|
1751
|
-
# Build a minimal Slack client for posting
|
|
1752
1798
|
slack_client = None
|
|
1753
1799
|
if cfg.slack_bot_token and channel:
|
|
1754
1800
|
try:
|
|
@@ -1757,22 +1803,24 @@ async def _execute_monitor(monitor: dict, cfg_loader: ConfigLoader, store: State
|
|
|
1757
1803
|
except Exception:
|
|
1758
1804
|
pass
|
|
1759
1805
|
|
|
1760
|
-
# Run each step
|
|
1761
|
-
|
|
1806
|
+
# Run each step and collect formatted output
|
|
1807
|
+
formatted_parts: list[str] = []
|
|
1762
1808
|
for step in steps:
|
|
1763
1809
|
tool = step.get("tool", "")
|
|
1764
1810
|
inputs = step.get("inputs", {})
|
|
1765
1811
|
try:
|
|
1766
|
-
|
|
1812
|
+
raw = await asyncio.wait_for(
|
|
1767
1813
|
_run_tool(tool, inputs, cfg_loader, store,
|
|
1768
1814
|
slack_client=slack_client, user_id=user_id, channel=channel),
|
|
1769
1815
|
timeout=300,
|
|
1770
1816
|
)
|
|
1771
|
-
|
|
1817
|
+
part = _format_monitor_step_output(tool, raw)
|
|
1818
|
+
if part:
|
|
1819
|
+
formatted_parts.append(part)
|
|
1772
1820
|
except asyncio.TimeoutError:
|
|
1773
|
-
|
|
1821
|
+
formatted_parts.append(f":warning: `{tool}` timed out after 5 minutes.")
|
|
1774
1822
|
except Exception as e:
|
|
1775
|
-
|
|
1823
|
+
formatted_parts.append(f":warning: `{tool}` error: {e}")
|
|
1776
1824
|
|
|
1777
1825
|
runs_after = runs_so_far + 1
|
|
1778
1826
|
now = datetime.now(timezone.utc)
|
|
@@ -1784,8 +1832,7 @@ async def _execute_monitor(monitor: dict, cfg_loader: ConfigLoader, store: State
|
|
|
1784
1832
|
if stop_at:
|
|
1785
1833
|
try:
|
|
1786
1834
|
stop_dt = datetime.fromisoformat(stop_at.replace("Z", "+00:00"))
|
|
1787
|
-
|
|
1788
|
-
if next_dt >= stop_dt:
|
|
1835
|
+
if now + timedelta(seconds=interval_s) >= stop_dt:
|
|
1789
1836
|
done = True
|
|
1790
1837
|
except Exception:
|
|
1791
1838
|
pass
|
|
@@ -1796,26 +1843,28 @@ async def _execute_monitor(monitor: dict, cfg_loader: ConfigLoader, store: State
|
|
|
1796
1843
|
if not slack_client or not channel:
|
|
1797
1844
|
return
|
|
1798
1845
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1846
|
+
# Only post if there is something to show
|
|
1847
|
+
if formatted_parts:
|
|
1848
|
+
combined = "\n".join(formatted_parts)
|
|
1849
|
+
MAX_LEN = 3800
|
|
1850
|
+
if len(combined) > MAX_LEN:
|
|
1851
|
+
combined = combined[:MAX_LEN] + f"\n_…truncated ({len(combined)} chars total)_"
|
|
1852
|
+
header = f":repeat: *Monitor `{mon_id}`* ({mon_name}) — run #{runs_after}"
|
|
1853
|
+
if done:
|
|
1854
|
+
header += " _(final)_"
|
|
1855
|
+
try:
|
|
1856
|
+
await slack_client.chat_postMessage(channel=channel, text=f"{header}\n{combined}")
|
|
1857
|
+
except Exception as e:
|
|
1858
|
+
logger.warning("Monitor %s: Slack post failed: %s", mon_id, e)
|
|
1803
1859
|
|
|
1804
|
-
header = f":repeat: *Monitor `{mon_id}`* ({mon_name}) — run #{runs_after}"
|
|
1805
1860
|
if done:
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
text = f"{header}\n{combined}" if combined.strip() else f"{header}\n_no output_"
|
|
1809
|
-
|
|
1810
|
-
try:
|
|
1811
|
-
await slack_client.chat_postMessage(channel=channel, text=text)
|
|
1812
|
-
if done:
|
|
1861
|
+
try:
|
|
1813
1862
|
await slack_client.chat_postMessage(
|
|
1814
1863
|
channel=channel,
|
|
1815
|
-
text=f":checkered_flag: Monitor `{mon_id}` finished after {runs_after} run(s).",
|
|
1864
|
+
text=f":checkered_flag: Monitor `{mon_id}` ({mon_name}) finished after {runs_after} run(s).",
|
|
1816
1865
|
)
|
|
1817
|
-
|
|
1818
|
-
|
|
1866
|
+
except Exception as e:
|
|
1867
|
+
logger.warning("Monitor %s: Slack done-post failed: %s", mon_id, e)
|
|
1819
1868
|
|
|
1820
1869
|
|
|
1821
1870
|
async def _monitor_runner_loop(cfg_loader: ConfigLoader, store: StateStore) -> None:
|
|
@@ -378,7 +378,7 @@ reply with a grouped summary like this:
|
|
|
378
378
|
ask_logs, list_recent_commits, check_health.
|
|
379
379
|
Always confirm to the user with the monitor ID and stop condition before creating.
|
|
380
380
|
• `stop_monitor` — cancel a monitor by ID, or pass "all" to cancel all in this channel
|
|
381
|
-
• `list_monitors` — show all active
|
|
381
|
+
• `list_monitors` — show all active monitors
|
|
382
382
|
|
|
383
383
|
*File sharing*
|
|
384
384
|
• `post_file` — upload any output as a Slack file (logs, diffs, reports)
|
|
@@ -537,6 +537,15 @@ Response length:
|
|
|
537
537
|
- Status/health data: one line. Don't re-list every field from the JSON.
|
|
538
538
|
- Actions (fix, merge, release): brief confirmation of what happened.
|
|
539
539
|
|
|
540
|
+
Formatting — always use Slack code blocks (triple backticks) for:
|
|
541
|
+
- Log lines / log output
|
|
542
|
+
- JSON or structured data
|
|
543
|
+
- Stack traces
|
|
544
|
+
- Code snippets
|
|
545
|
+
- File diffs
|
|
546
|
+
- Any multi-line technical content
|
|
547
|
+
Never paste raw JSON or log text as plain prose.
|
|
548
|
+
|
|
540
549
|
When to act vs. when to ask:
|
|
541
550
|
- Any read/investigate tool → call immediately without asking permission.
|
|
542
551
|
Never say "Want me to check?" — just check and report results.
|
|
@@ -1399,7 +1408,7 @@ _TOOLS = [
|
|
|
1399
1408
|
{
|
|
1400
1409
|
"name": "list_monitors",
|
|
1401
1410
|
"description": (
|
|
1402
|
-
"List active
|
|
1411
|
+
"List all active scheduled monitors. "
|
|
1403
1412
|
"Use for: 'what monitors are running?', 'show scheduled tasks', 'list active monitors'."
|
|
1404
1413
|
),
|
|
1405
1414
|
"input_schema": {"type": "object", "properties": {}},
|
|
@@ -2134,6 +2143,41 @@ def _git_pull(path: Path) -> dict:
|
|
|
2134
2143
|
|
|
2135
2144
|
# ── Log-source name resolver ──────────────────────────────────────────────────
|
|
2136
2145
|
|
|
2146
|
+
def _resolve_source_hint(hint: str, cfg_loader, store=None) -> str:
|
|
2147
|
+
"""
|
|
2148
|
+
Translate a short alias (e.g. 'SSOLWA') to the canonical log-source / repo name
|
|
2149
|
+
so that _filter_log_sources and _auto_health_check can match it.
|
|
2150
|
+
|
|
2151
|
+
Resolution order:
|
|
2152
|
+
1. Already matches a log-source name → return as-is
|
|
2153
|
+
2. Matches a repo's SERVICE_ALIASES → return the repo name
|
|
2154
|
+
3. DB aliases → return the stored repo name
|
|
2155
|
+
4. Fallback → return hint unchanged
|
|
2156
|
+
"""
|
|
2157
|
+
if not hint:
|
|
2158
|
+
return hint
|
|
2159
|
+
hint_lower = hint.lower()
|
|
2160
|
+
|
|
2161
|
+
# 1. Direct log-source match — no translation needed
|
|
2162
|
+
for src_name in cfg_loader.log_sources:
|
|
2163
|
+
if hint_lower in src_name.lower():
|
|
2164
|
+
return hint
|
|
2165
|
+
|
|
2166
|
+
# 2. Config-declared SERVICE_ALIASES
|
|
2167
|
+
for repo_name, repo in cfg_loader.repos.items():
|
|
2168
|
+
declared = getattr(repo, "service_aliases", [])
|
|
2169
|
+
if any(hint_lower == a.lower() for a in declared):
|
|
2170
|
+
return repo_name
|
|
2171
|
+
|
|
2172
|
+
# 3. DB aliases
|
|
2173
|
+
if store:
|
|
2174
|
+
alias = store.get_service_alias(hint)
|
|
2175
|
+
if alias:
|
|
2176
|
+
return alias
|
|
2177
|
+
|
|
2178
|
+
return hint
|
|
2179
|
+
|
|
2180
|
+
|
|
2137
2181
|
def _filter_log_sources(props_files: list, source_hint: str) -> list:
|
|
2138
2182
|
"""
|
|
2139
2183
|
Return the subset of props_files whose log source matches source_hint.
|
|
@@ -2856,7 +2900,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2856
2900
|
|
|
2857
2901
|
if name == "search_logs":
|
|
2858
2902
|
query = inputs.get("query", "")
|
|
2859
|
-
source = inputs.get("source", "").lower()
|
|
2903
|
+
source = _resolve_source_hint(inputs.get("source", "").lower(), cfg_loader, store)
|
|
2860
2904
|
max_matches = int(inputs.get("max_matches", 30))
|
|
2861
2905
|
tail_override = inputs.get("tail")
|
|
2862
2906
|
|
|
@@ -3007,7 +3051,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
3007
3051
|
return line.strip()[:40]
|
|
3008
3052
|
|
|
3009
3053
|
query_f = inputs.get("query", "")
|
|
3010
|
-
source_f = inputs.get("source", "").lower()
|
|
3054
|
+
source_f = _resolve_source_hint(inputs.get("source", "").lower(), cfg_loader, store)
|
|
3011
3055
|
since_hours = inputs.get("since_hours")
|
|
3012
3056
|
max_matches = int(inputs.get("max_matches", 300))
|
|
3013
3057
|
case_flag = 0 if inputs.get("case_sensitive") else _re.IGNORECASE
|
|
@@ -3227,7 +3271,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
3227
3271
|
return json.dumps({"results": results})
|
|
3228
3272
|
|
|
3229
3273
|
if name == "fetch_logs":
|
|
3230
|
-
source_filter = inputs.get("source", "").lower()
|
|
3274
|
+
source_filter = _resolve_source_hint(inputs.get("source", "").lower(), cfg_loader, store)
|
|
3231
3275
|
debug = bool(inputs.get("debug", False))
|
|
3232
3276
|
tail_override = inputs.get("tail")
|
|
3233
3277
|
grep_override = inputs.get("grep_filter", "")
|
|
@@ -3548,9 +3592,9 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
3548
3592
|
return json.dumps({"error": f"Monitor '{mon_id}' not found or already stopped."})
|
|
3549
3593
|
|
|
3550
3594
|
if name == "list_monitors":
|
|
3551
|
-
monitors = store.
|
|
3595
|
+
monitors = store.list_active_monitors()
|
|
3552
3596
|
if not monitors:
|
|
3553
|
-
return json.dumps({"monitors": [], "message": "No monitors
|
|
3597
|
+
return json.dumps({"monitors": [], "message": "No active monitors."})
|
|
3554
3598
|
result = []
|
|
3555
3599
|
for _m in monitors:
|
|
3556
3600
|
_runs_left = None
|
|
@@ -502,21 +502,22 @@ class StateStore:
|
|
|
502
502
|
return [dict(r) for r in rows]
|
|
503
503
|
|
|
504
504
|
def mark_monitor_ran(self, id: str, next_run_at: str, done: bool = False) -> None:
|
|
505
|
-
status = "done" if done else "active"
|
|
506
505
|
with self._conn() as conn:
|
|
507
506
|
self._ensure_monitors_table(conn)
|
|
508
|
-
|
|
509
|
-
"
|
|
510
|
-
|
|
511
|
-
(
|
|
512
|
-
|
|
507
|
+
if done:
|
|
508
|
+
conn.execute("DELETE FROM monitors WHERE id = ?", (id,))
|
|
509
|
+
else:
|
|
510
|
+
conn.execute(
|
|
511
|
+
"UPDATE monitors SET runs_so_far = runs_so_far + 1, last_run_at = ?, "
|
|
512
|
+
"next_run_at = ? WHERE id = ?",
|
|
513
|
+
(_now(), next_run_at, id),
|
|
514
|
+
)
|
|
513
515
|
|
|
514
516
|
def cancel_monitor(self, id: str) -> bool:
|
|
515
517
|
with self._conn() as conn:
|
|
516
518
|
self._ensure_monitors_table(conn)
|
|
517
519
|
cur = conn.execute(
|
|
518
|
-
"
|
|
519
|
-
(id,),
|
|
520
|
+
"DELETE FROM monitors WHERE id = ? AND status = 'active'", (id,)
|
|
520
521
|
)
|
|
521
522
|
return cur.rowcount > 0
|
|
522
523
|
|
|
@@ -525,14 +526,11 @@ class StateStore:
|
|
|
525
526
|
self._ensure_monitors_table(conn)
|
|
526
527
|
if channel:
|
|
527
528
|
cur = conn.execute(
|
|
528
|
-
"
|
|
529
|
-
"WHERE status = 'active' AND channel = ?",
|
|
529
|
+
"DELETE FROM monitors WHERE status = 'active' AND channel = ?",
|
|
530
530
|
(channel,),
|
|
531
531
|
)
|
|
532
532
|
else:
|
|
533
|
-
cur = conn.execute(
|
|
534
|
-
"UPDATE monitors SET status = 'cancelled' WHERE status = 'active'"
|
|
535
|
-
)
|
|
533
|
+
cur = conn.execute("DELETE FROM monitors WHERE status = 'active'")
|
|
536
534
|
return cur.rowcount
|
|
537
535
|
|
|
538
536
|
# ── Pending bot-message routing questions ─────────────────────────────────
|