@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:28:47.362Z
1
+ 2026-04-21T05:59:33.396Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-21T05:29:15.578Z",
3
- "checkpoint_at": "2026-04-21T05:29:15.579Z",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.5.53",
3
+ "version": "1.5.54",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1 +1 @@
1
- __version__ = "1.5.53"
1
+ __version__ = "1.5.54"
@@ -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 combined output to Slack."""
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 = monitor["id"]
1740
- channel = monitor.get("channel", "")
1741
- user_id = monitor.get("user_id", "")
1742
- steps = _json.loads(monitor.get("steps_json") or "[]")
1743
- interval_s = int(monitor.get("interval_seconds", 300))
1744
- stop_at = monitor.get("stop_at")
1745
- max_runs = monitor.get("max_runs")
1746
- runs_so_far = int(monitor.get("runs_so_far") or 0)
1747
- mon_name = monitor.get("name") or " → ".join(s.get("tool", "") for s in steps)
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
- outputs: list[str] = []
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
- result = await asyncio.wait_for(
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
- outputs.append(result)
1817
+ part = _format_monitor_step_output(tool, raw)
1818
+ if part:
1819
+ formatted_parts.append(part)
1772
1820
  except asyncio.TimeoutError:
1773
- outputs.append(f"_Step `{tool}` timed out after 5 minutes._")
1821
+ formatted_parts.append(f":warning: `{tool}` timed out after 5 minutes.")
1774
1822
  except Exception as e:
1775
- outputs.append(f"_Step `{tool}` error: {e}_")
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
- next_dt = now + timedelta(seconds=interval_s)
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
- combined = "\n\n".join(o for o in outputs if o.strip())
1800
- MAX_LEN = 3000
1801
- if len(combined) > MAX_LEN:
1802
- combined = combined[:MAX_LEN] + f"\n_(output truncated — {len(combined)} chars total)_"
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
- header += " _(final run)_"
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
- except Exception as e:
1818
- logger.warning("Monitor %s: failed to post result to Slack: %s", mon_id, e)
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 and recent monitors with their status and next-run time
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 and recently completed scheduled monitors. "
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.list_all_monitors()
3595
+ monitors = store.list_active_monitors()
3552
3596
  if not monitors:
3553
- return json.dumps({"monitors": [], "message": "No monitors found."})
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
- conn.execute(
509
- "UPDATE monitors SET runs_so_far = runs_so_far + 1, last_run_at = ?, "
510
- "next_run_at = ?, status = ? WHERE id = ?",
511
- (_now(), next_run_at, status, id),
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
- "UPDATE monitors SET status = 'cancelled' WHERE id = ? AND status = 'active'",
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
- "UPDATE monitors SET status = 'cancelled' "
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 ─────────────────────────────────