@misterhuydo/sentinel 1.0.52 → 1.0.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 +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +1 -3
- package/python/sentinel/issue_watcher.py +9 -1
- package/python/sentinel/sentinel_boss.py +248 -7
- package/python/sentinel/slack_bot.py +66 -73
- package/python/sentinel/state_store.py +44 -4
- package/templates/sentinel.properties +5 -8
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-22T12:03:28.852Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-22T11:59:57.164Z",
|
|
3
|
+
"checkpoint_at": "2026-03-22T11:59:57.165Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -60,8 +60,7 @@ class SentinelConfig:
|
|
|
60
60
|
slack_bot_token: str = "" # xoxb-...
|
|
61
61
|
slack_app_token: str = "" # xapp-... (Socket Mode)
|
|
62
62
|
slack_channel: str = "" # optional: restrict to one channel ID or name
|
|
63
|
-
|
|
64
|
-
slack_watch_bot_ids: list[str] = field(default_factory=list) # optional: only watch these bot IDs
|
|
63
|
+
slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
|
|
65
64
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
66
65
|
|
|
67
66
|
|
|
@@ -155,7 +154,6 @@ class ConfigLoader:
|
|
|
155
154
|
c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
|
|
156
155
|
c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
|
|
157
156
|
c.slack_channel = d.get("SLACK_CHANNEL", "")
|
|
158
|
-
c.slack_watch_channels = _csv(d.get("SLACK_WATCH_CHANNELS", ""))
|
|
159
157
|
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
160
158
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
161
159
|
self.sentinel = c
|
|
@@ -89,8 +89,16 @@ def scan_issues(project_dir: Path) -> list[IssueEvent]:
|
|
|
89
89
|
issues_dir = project_dir / "issues"
|
|
90
90
|
issues_dir.mkdir(exist_ok=True)
|
|
91
91
|
|
|
92
|
+
def _priority(p: Path) -> tuple:
|
|
93
|
+
# slack-* (human) first, bot-* (bot) last, everything else in between
|
|
94
|
+
if p.name.startswith("slack-"):
|
|
95
|
+
return (0, p.name)
|
|
96
|
+
if p.name.startswith("bot-"):
|
|
97
|
+
return (2, p.name)
|
|
98
|
+
return (1, p.name)
|
|
99
|
+
|
|
92
100
|
events = []
|
|
93
|
-
for f in sorted(issues_dir.iterdir()):
|
|
101
|
+
for f in sorted(issues_dir.iterdir(), key=_priority):
|
|
94
102
|
if not f.is_file() or f.name.startswith("."):
|
|
95
103
|
continue
|
|
96
104
|
if f.suffix.lower() in _BINARY_EXTENSIONS:
|
|
@@ -83,6 +83,22 @@ What you can do (tools available):
|
|
|
83
83
|
e.g. "fetch logs", "try fetch_log.sh for SSOLWA", "fetch logs with debug",
|
|
84
84
|
"grab latest logs from STS", "fetch logs without filter"
|
|
85
85
|
|
|
86
|
+
15. watch_bot — Register a Slack bot for passive monitoring. Every message it posts is
|
|
87
|
+
auto-queued as an issue in the bot's registered project.
|
|
88
|
+
ALWAYS requires a project — infer from context or ask the user first.
|
|
89
|
+
e.g. "listen to @alertbot", "watch @bot1 @bot2 for project 1881", "monitor @errorbot"
|
|
90
|
+
|
|
91
|
+
16. unwatch_bot — Remove a Slack bot from the passive watch list.
|
|
92
|
+
e.g. "stop watching @alertbot", "unwatch @errorbot"
|
|
93
|
+
|
|
94
|
+
17. list_watched_bots — Show all Slack bots currently being passively monitored and which projects
|
|
95
|
+
they are delivering to.
|
|
96
|
+
e.g. "which bots are you watching?", "list monitored bots"
|
|
97
|
+
|
|
98
|
+
18. upgrade_sentinel — Pull the latest Sentinel agent code, update Python deps, and restart the
|
|
99
|
+
process. Safe to run at any time — no restart if already up to date.
|
|
100
|
+
e.g. "upgrade sentinel", "update sentinel", "upgrade yourself"
|
|
101
|
+
|
|
86
102
|
When someone asks what you can do, what you support, what your capabilities are, or how you can help,
|
|
87
103
|
reply with a short summary grouped by category:
|
|
88
104
|
|
|
@@ -109,6 +125,14 @@ reply with a short summary grouped by category:
|
|
|
109
125
|
• `pull_repo` — git pull on managed application repos — "pull latest code"
|
|
110
126
|
• `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
|
|
111
127
|
|
|
128
|
+
*Slack bot watching*
|
|
129
|
+
• `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues — "listen to @alertbot"
|
|
130
|
+
• `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
|
|
131
|
+
• `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
|
|
132
|
+
|
|
133
|
+
*Self-management*
|
|
134
|
+
• `upgrade_sentinel` — git pull + pip install + restart — "upgrade sentinel", "update yourself"
|
|
135
|
+
|
|
112
136
|
Tone: direct, professional, like a senior engineer who owns the system.
|
|
113
137
|
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
114
138
|
If you don't know something, use a tool to find out before saying you don't know.
|
|
@@ -338,6 +362,71 @@ _TOOLS = [
|
|
|
338
362
|
},
|
|
339
363
|
},
|
|
340
364
|
},
|
|
365
|
+
{
|
|
366
|
+
"name": "watch_bot",
|
|
367
|
+
"description": (
|
|
368
|
+
"Tell Sentinel to passively monitor a Slack bot — queuing its messages as issues. "
|
|
369
|
+
"Extract all <@UXXXXXX> user IDs from the message and pass them here. "
|
|
370
|
+
"Sentinel verifies each is actually a bot (not a human) before adding to the watch list. "
|
|
371
|
+
"IMPORTANT: a bot watcher is only useful if its issues can be delivered to a project. "
|
|
372
|
+
"Try to infer the project from context (bot name, prior messages, available projects). "
|
|
373
|
+
"If it cannot be determined, do NOT call this tool — instead ask the user which project "
|
|
374
|
+
"the bot's alerts belong to, then call this tool with the project filled in. "
|
|
375
|
+
"Use for: 'listen to @alertbot', 'watch @bot1 @bot2', 'monitor @errorbot'."
|
|
376
|
+
),
|
|
377
|
+
"input_schema": {
|
|
378
|
+
"type": "object",
|
|
379
|
+
"properties": {
|
|
380
|
+
"user_ids": {
|
|
381
|
+
"type": "array",
|
|
382
|
+
"items": {"type": "string"},
|
|
383
|
+
"description": "Slack user IDs to watch — extract from <@UXXXXXX> patterns in the message",
|
|
384
|
+
},
|
|
385
|
+
"project": {
|
|
386
|
+
"type": "string",
|
|
387
|
+
"description": "Project short name this bot's issues should be routed to (e.g. '1881', 'elprint'). Infer from context or ask user before calling.",
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
"required": ["user_ids"],
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"name": "unwatch_bot",
|
|
395
|
+
"description": (
|
|
396
|
+
"Stop Sentinel from monitoring a Slack bot. "
|
|
397
|
+
"Use for: 'stop watching @alertbot', 'unwatch @bot', 'remove @errorbot from watchers'."
|
|
398
|
+
),
|
|
399
|
+
"input_schema": {
|
|
400
|
+
"type": "object",
|
|
401
|
+
"properties": {
|
|
402
|
+
"user_ids": {
|
|
403
|
+
"type": "array",
|
|
404
|
+
"items": {"type": "string"},
|
|
405
|
+
"description": "Slack user IDs to remove from the watch list",
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
"required": ["user_ids"],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"name": "list_watched_bots",
|
|
413
|
+
"description": (
|
|
414
|
+
"List all Slack bots Sentinel is currently monitoring passively. "
|
|
415
|
+
"Use for: 'who are you watching?', 'which bots are you monitoring?', 'list watched bots'."
|
|
416
|
+
),
|
|
417
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
"name": "upgrade_sentinel",
|
|
421
|
+
"description": (
|
|
422
|
+
"Upgrade the Sentinel agent itself: git pull the latest code, update Python deps, "
|
|
423
|
+
"then restart the process. Safe to call at any time — if already up to date, "
|
|
424
|
+
"no restart is triggered. "
|
|
425
|
+
"Use for: 'upgrade sentinel', 'update sentinel', 'upgrade yourself', "
|
|
426
|
+
"'pull latest sentinel code', 'restart sentinel after upgrade'."
|
|
427
|
+
),
|
|
428
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
429
|
+
},
|
|
341
430
|
]
|
|
342
431
|
|
|
343
432
|
|
|
@@ -404,7 +493,7 @@ def _git_pull(path: Path) -> dict:
|
|
|
404
493
|
|
|
405
494
|
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
406
495
|
|
|
407
|
-
def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
|
|
496
|
+
async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None) -> str:
|
|
408
497
|
if name == "get_status":
|
|
409
498
|
hours = int(inputs.get("hours", 24))
|
|
410
499
|
errors = store.get_recent_errors(hours)
|
|
@@ -700,6 +789,156 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
|
|
|
700
789
|
|
|
701
790
|
return json.dumps({"fetched": len(results), "results": results})
|
|
702
791
|
|
|
792
|
+
if name == "watch_bot":
|
|
793
|
+
user_ids = inputs.get("user_ids", [])
|
|
794
|
+
project_arg = inputs.get("project", "").strip()
|
|
795
|
+
if not user_ids:
|
|
796
|
+
return json.dumps({"error": "No user_ids provided"})
|
|
797
|
+
|
|
798
|
+
# Resolve + validate project — required for bot issue routing
|
|
799
|
+
resolved_project = ""
|
|
800
|
+
if project_arg:
|
|
801
|
+
project_dirs = _find_project_dirs(project_arg)
|
|
802
|
+
if not project_dirs:
|
|
803
|
+
all_names = [_read_project_name(d) for d in _find_project_dirs()]
|
|
804
|
+
return json.dumps({
|
|
805
|
+
"error": f"No project found matching '{project_arg}'",
|
|
806
|
+
"available_projects": all_names,
|
|
807
|
+
"action_needed": "Ask the user which project these bot alerts belong to.",
|
|
808
|
+
})
|
|
809
|
+
if len(project_dirs) > 1:
|
|
810
|
+
matches = [_read_project_name(d) for d in project_dirs]
|
|
811
|
+
return json.dumps({
|
|
812
|
+
"error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
|
|
813
|
+
"action_needed": "Ask the user to clarify which project.",
|
|
814
|
+
})
|
|
815
|
+
resolved_project = _read_project_name(project_dirs[0])
|
|
816
|
+
else:
|
|
817
|
+
all_projects = _find_project_dirs()
|
|
818
|
+
if len(all_projects) == 1:
|
|
819
|
+
# Single project in workspace — auto-assign
|
|
820
|
+
resolved_project = _read_project_name(all_projects[0])
|
|
821
|
+
elif all_projects:
|
|
822
|
+
all_names = [_read_project_name(d) for d in all_projects]
|
|
823
|
+
return json.dumps({
|
|
824
|
+
"error": "Cannot determine which project these bot alerts belong to.",
|
|
825
|
+
"available_projects": all_names,
|
|
826
|
+
"action_needed": "Ask the user to specify the project, then retry with project filled in.",
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
results = []
|
|
830
|
+
for uid in user_ids:
|
|
831
|
+
if not slack_client:
|
|
832
|
+
results.append({"user_id": uid, "status": "error", "reason": "no Slack client available"})
|
|
833
|
+
continue
|
|
834
|
+
try:
|
|
835
|
+
info = await slack_client.users_info(user=uid)
|
|
836
|
+
user = info.get("user", {})
|
|
837
|
+
if not user.get("is_bot", False):
|
|
838
|
+
results.append({"user_id": uid, "status": "skipped", "reason": "not a bot — only bots can be watched passively"})
|
|
839
|
+
continue
|
|
840
|
+
bot_name = user.get("real_name") or user.get("name") or uid
|
|
841
|
+
store.add_watched_bot(uid, bot_name, added_by="boss", project_name=resolved_project)
|
|
842
|
+
logger.info("Boss: now watching bot %s (%s) → project '%s'", bot_name, uid, resolved_project or "unset")
|
|
843
|
+
results.append({"user_id": uid, "bot_name": bot_name, "project": resolved_project, "status": "watching"})
|
|
844
|
+
except Exception as e:
|
|
845
|
+
results.append({"user_id": uid, "status": "error", "reason": str(e)})
|
|
846
|
+
return json.dumps({"results": results})
|
|
847
|
+
|
|
848
|
+
if name == "unwatch_bot":
|
|
849
|
+
user_ids = inputs.get("user_ids", [])
|
|
850
|
+
if not user_ids:
|
|
851
|
+
return json.dumps({"error": "No user_ids provided"})
|
|
852
|
+
results = []
|
|
853
|
+
for uid in user_ids:
|
|
854
|
+
removed = store.remove_watched_bot(uid)
|
|
855
|
+
logger.info("Boss: unwatch bot %s → %s", uid, "removed" if removed else "not found")
|
|
856
|
+
results.append({"user_id": uid, "status": "removed" if removed else "not found"})
|
|
857
|
+
return json.dumps({"results": results})
|
|
858
|
+
|
|
859
|
+
if name == "list_watched_bots":
|
|
860
|
+
bots = store.get_watched_bots()
|
|
861
|
+
return json.dumps({
|
|
862
|
+
"count": len(bots),
|
|
863
|
+
"bots": [
|
|
864
|
+
{
|
|
865
|
+
"bot_id": b["bot_id"],
|
|
866
|
+
"bot_name": b["bot_name"],
|
|
867
|
+
"project": b.get("project_name") or "",
|
|
868
|
+
"added_by": b["added_by"],
|
|
869
|
+
"added_at": b["added_at"],
|
|
870
|
+
}
|
|
871
|
+
for b in bots
|
|
872
|
+
],
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
if name == "upgrade_sentinel":
|
|
876
|
+
import threading
|
|
877
|
+
import signal as _sig
|
|
878
|
+
|
|
879
|
+
code_dir = Path(__file__).resolve().parent.parent # sentinel repo root
|
|
880
|
+
project_dir = Path(".").resolve()
|
|
881
|
+
steps: list[dict] = []
|
|
882
|
+
|
|
883
|
+
# Step 1: git pull the sentinel agent code
|
|
884
|
+
pull = _git_pull(code_dir)
|
|
885
|
+
steps.append({"step": "git_pull", **pull})
|
|
886
|
+
already_latest = "already up to date" in pull.get("detail", "").lower()
|
|
887
|
+
|
|
888
|
+
if pull["status"] == "error":
|
|
889
|
+
return json.dumps({"status": "error", "steps": steps,
|
|
890
|
+
"note": "git pull failed — check network / SSH key"})
|
|
891
|
+
|
|
892
|
+
# Step 2: pip install (update Python deps)
|
|
893
|
+
venv_pip = code_dir / ".venv" / "bin" / "pip"
|
|
894
|
+
pip_cmd = str(venv_pip) if venv_pip.exists() else "pip3"
|
|
895
|
+
req_file = code_dir / "requirements.txt"
|
|
896
|
+
if req_file.exists():
|
|
897
|
+
try:
|
|
898
|
+
r = subprocess.run(
|
|
899
|
+
[pip_cmd, "install", "-q", "-r", str(req_file)],
|
|
900
|
+
capture_output=True, text=True, timeout=120,
|
|
901
|
+
)
|
|
902
|
+
steps.append({
|
|
903
|
+
"step": "pip_install",
|
|
904
|
+
"status": "ok" if r.returncode == 0 else "warn",
|
|
905
|
+
"detail": (r.stderr or "").strip()[:200],
|
|
906
|
+
})
|
|
907
|
+
except Exception as e:
|
|
908
|
+
steps.append({"step": "pip_install", "status": "warn", "detail": str(e)})
|
|
909
|
+
|
|
910
|
+
if already_latest:
|
|
911
|
+
return json.dumps({
|
|
912
|
+
"status": "already_latest",
|
|
913
|
+
"steps": steps,
|
|
914
|
+
"note": "Sentinel is already up to date. No restart needed.",
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
# Step 3: restart — schedule after response is sent
|
|
918
|
+
stop_sh = project_dir / "stop.sh"
|
|
919
|
+
start_sh = project_dir / "start.sh"
|
|
920
|
+
if stop_sh.exists() and start_sh.exists():
|
|
921
|
+
def _restart_scripts():
|
|
922
|
+
import time; time.sleep(2)
|
|
923
|
+
subprocess.Popen(
|
|
924
|
+
f"bash {stop_sh} && sleep 2 && bash {start_sh}",
|
|
925
|
+
shell=True,
|
|
926
|
+
)
|
|
927
|
+
threading.Thread(target=_restart_scripts, daemon=True).start()
|
|
928
|
+
restart_method = "stop.sh + start.sh"
|
|
929
|
+
else:
|
|
930
|
+
# SIGTERM self — systemd (Restart=always) will bring it back up
|
|
931
|
+
threading.Timer(2.0, lambda: os.kill(os.getpid(), _sig.SIGTERM)).start()
|
|
932
|
+
restart_method = "SIGTERM → systemd restart"
|
|
933
|
+
|
|
934
|
+
steps.append({"step": "restart", "status": "scheduled", "method": restart_method})
|
|
935
|
+
logger.info("Boss: upgrade_sentinel complete, restarting via %s", restart_method)
|
|
936
|
+
return json.dumps({
|
|
937
|
+
"status": "ok",
|
|
938
|
+
"steps": steps,
|
|
939
|
+
"note": "Upgrade complete. Sentinel is restarting — give it a few seconds then I'll be back.",
|
|
940
|
+
})
|
|
941
|
+
|
|
703
942
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
704
943
|
|
|
705
944
|
|
|
@@ -713,10 +952,11 @@ async def _handle_with_cli(
|
|
|
713
952
|
history: list,
|
|
714
953
|
cfg_loader,
|
|
715
954
|
store,
|
|
955
|
+
slack_client=None,
|
|
716
956
|
) -> tuple[str, bool]:
|
|
717
957
|
"""Fallback: use `claude --print` for users without an Anthropic API key."""
|
|
718
|
-
status_json = _run_tool("get_status", {"hours": 24}, cfg_loader, store)
|
|
719
|
-
prs_json = _run_tool("list_pending_prs", {}, cfg_loader, store)
|
|
958
|
+
status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
|
|
959
|
+
prs_json = await _run_tool("list_pending_prs", {}, cfg_loader, store)
|
|
720
960
|
|
|
721
961
|
# Pre-fetch log search if the message is a search request.
|
|
722
962
|
# Use quoted strings as the query, or fall back to the full message.
|
|
@@ -726,7 +966,7 @@ async def _handle_with_cli(
|
|
|
726
966
|
if any(kw in message.lower() for kw in _search_kws):
|
|
727
967
|
quoted = re.findall(r'"([^"]+)"', message)
|
|
728
968
|
query = quoted[0] if quoted else message
|
|
729
|
-
search_json = _run_tool("search_logs", {"query": query}, cfg_loader, store)
|
|
969
|
+
search_json = await _run_tool("search_logs", {"query": query}, cfg_loader, store)
|
|
730
970
|
|
|
731
971
|
paused = Path("SENTINEL_PAUSE").exists()
|
|
732
972
|
repos = list(cfg_loader.repos.keys())
|
|
@@ -784,7 +1024,7 @@ async def _handle_with_cli(
|
|
|
784
1024
|
action = json.loads(m.group(1))
|
|
785
1025
|
name = action.pop("action", "")
|
|
786
1026
|
if name:
|
|
787
|
-
result_str = _run_tool(name, action, cfg_loader, store)
|
|
1027
|
+
result_str = await _run_tool(name, action, cfg_loader, store)
|
|
788
1028
|
logger.info("Boss CLI action: %s → %s", name, result_str[:80])
|
|
789
1029
|
except Exception as e:
|
|
790
1030
|
logger.warning("Boss action parse error: %s", e)
|
|
@@ -805,6 +1045,7 @@ async def handle_message(
|
|
|
805
1045
|
history: list,
|
|
806
1046
|
cfg_loader,
|
|
807
1047
|
store,
|
|
1048
|
+
slack_client=None,
|
|
808
1049
|
) -> tuple[str, bool]:
|
|
809
1050
|
"""
|
|
810
1051
|
Process one user message through the Sentinel Boss (Claude with tool use).
|
|
@@ -830,7 +1071,7 @@ async def handle_message(
|
|
|
830
1071
|
|
|
831
1072
|
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
832
1073
|
if not api_key:
|
|
833
|
-
return await _handle_with_cli(message, history, cfg_loader, store)
|
|
1074
|
+
return await _handle_with_cli(message, history, cfg_loader, store, slack_client=slack_client)
|
|
834
1075
|
|
|
835
1076
|
client = anthropic.Anthropic(api_key=api_key)
|
|
836
1077
|
|
|
@@ -880,7 +1121,7 @@ async def handle_message(
|
|
|
880
1121
|
messages.append({"role": "assistant", "content": response.content})
|
|
881
1122
|
tool_results = []
|
|
882
1123
|
for tc in tool_blocks:
|
|
883
|
-
result = _run_tool(tc.name, tc.input, cfg_loader, store)
|
|
1124
|
+
result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client)
|
|
884
1125
|
logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
|
|
885
1126
|
tool_results.append({
|
|
886
1127
|
"type": "tool_result",
|
|
@@ -164,38 +164,11 @@ async def run_slack_bot(cfg_loader, store) -> None:
|
|
|
164
164
|
if _allowed(event.get("channel", "")):
|
|
165
165
|
await _dispatch(event, client, cfg_loader, store)
|
|
166
166
|
|
|
167
|
-
# ──
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
async def _resolve_watch_channels(client):
|
|
173
|
-
if _watch_resolved[0]:
|
|
174
|
-
return
|
|
175
|
-
_watch_resolved[0] = True
|
|
176
|
-
if not cfg.slack_watch_channels:
|
|
177
|
-
return
|
|
178
|
-
try:
|
|
179
|
-
resp = await client.conversations_list(
|
|
180
|
-
types="public_channel,private_channel", limit=1000
|
|
181
|
-
)
|
|
182
|
-
name_to_id = {ch["name"]: ch["id"] for ch in (resp.get("channels") or [])}
|
|
183
|
-
except Exception as e:
|
|
184
|
-
logger.warning("Could not list channels for watch resolution: %s", e)
|
|
185
|
-
name_to_id = {}
|
|
186
|
-
for raw in cfg.slack_watch_channels:
|
|
187
|
-
raw = raw.lstrip("#")
|
|
188
|
-
if raw.upper().startswith("C") and len(raw) >= 9:
|
|
189
|
-
_watch_ids.add(raw.upper())
|
|
190
|
-
logger.info("Sentinel watching channel (ID): %s", raw.upper())
|
|
191
|
-
elif raw in name_to_id:
|
|
192
|
-
_watch_ids.add(name_to_id[raw])
|
|
193
|
-
logger.info("Sentinel watching channel: #%s → %s", raw, name_to_id[raw])
|
|
194
|
-
else:
|
|
195
|
-
logger.warning("Watch channel '%s' not found — skipping", raw)
|
|
196
|
-
|
|
197
|
-
def _is_watch_channel(channel: str) -> bool:
|
|
198
|
-
return bool(_watch_ids) and channel in _watch_ids
|
|
167
|
+
# ── Passive bot watcher — seed DB from config on startup ─────────────────
|
|
168
|
+
for bot_id_cfg in cfg.slack_watch_bot_ids:
|
|
169
|
+
if bot_id_cfg and not store.is_watched_bot(bot_id_cfg):
|
|
170
|
+
store.add_watched_bot(bot_id_cfg, bot_id_cfg, added_by="config")
|
|
171
|
+
logger.info("Seeded watched bot from config: %s", bot_id_cfg)
|
|
199
172
|
|
|
200
173
|
@app.event("message")
|
|
201
174
|
async def on_message(event, client):
|
|
@@ -204,76 +177,80 @@ async def run_slack_bot(cfg_loader, store) -> None:
|
|
|
204
177
|
await _dispatch(event, client, cfg_loader, store)
|
|
205
178
|
return
|
|
206
179
|
|
|
207
|
-
# Passive bot watcher — bot messages
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if _is_watch_channel(event["channel"]):
|
|
212
|
-
await _handle_bot_message(event, client, cfg_loader, store)
|
|
180
|
+
# Passive bot watcher — bot messages from DB-registered bots
|
|
181
|
+
bot_id = event.get("bot_id", "")
|
|
182
|
+
if bot_id and event.get("channel") and store.is_watched_bot(bot_id):
|
|
183
|
+
await _handle_bot_message(event, client, cfg_loader, store)
|
|
213
184
|
|
|
214
185
|
# ── Start ─────────────────────────────────────────────────────────────────
|
|
215
186
|
|
|
216
187
|
handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
|
|
217
188
|
logger.info("Sentinel Boss connected to Slack (Socket Mode)")
|
|
218
|
-
|
|
219
|
-
|
|
189
|
+
watched = store.get_watched_bots()
|
|
190
|
+
if watched:
|
|
191
|
+
logger.info("Sentinel passively watching %d bot(s): %s",
|
|
192
|
+
len(watched), [b["bot_name"] for b in watched])
|
|
220
193
|
await handler.start_async()
|
|
221
194
|
|
|
222
195
|
|
|
223
196
|
# ── Bot-message watcher ───────────────────────────────────────────────────────
|
|
224
197
|
|
|
225
|
-
import re as _re
|
|
226
198
|
import uuid as _uuid
|
|
227
199
|
from pathlib import Path as _Path
|
|
228
200
|
|
|
229
|
-
# Patterns that indicate an error report worth queuing
|
|
230
|
-
_ERROR_RE = _re.compile(
|
|
231
|
-
r"(?:exception|error|failed|failure|fatal|traceback|stacktrace"
|
|
232
|
-
r"|null\s*pointer|out\s*of\s*memory|stack\s*overflow"
|
|
233
|
-
r"|5\d\d\b.*(?:error|failed)" # HTTP 5xx
|
|
234
|
-
r"|WARN|ERROR|FATAL|CRITICAL)",
|
|
235
|
-
_re.IGNORECASE,
|
|
236
|
-
)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _looks_like_error(text: str) -> bool:
|
|
240
|
-
"""Return True if the bot message appears to be an error report."""
|
|
241
|
-
return bool(_ERROR_RE.search(text))
|
|
242
|
-
|
|
243
201
|
|
|
244
202
|
async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
245
203
|
"""
|
|
246
|
-
Called when a bot posts
|
|
247
|
-
|
|
204
|
+
Called when a watched bot posts a message.
|
|
205
|
+
Queues the message as an issue in the bot's registered project directory.
|
|
206
|
+
All messages from watched bots are queued — no error pre-filtering.
|
|
248
207
|
"""
|
|
249
|
-
cfg = cfg_loader.sentinel
|
|
250
208
|
bot_id = event.get("bot_id", "")
|
|
251
209
|
channel = event.get("channel", "")
|
|
252
210
|
ts = event.get("ts", "")
|
|
253
211
|
|
|
254
|
-
#
|
|
255
|
-
|
|
212
|
+
# Avoid duplicate issues
|
|
213
|
+
dedup_key = f"slack-watch:{channel}:{ts}"
|
|
214
|
+
if store.is_seen_dedup(dedup_key):
|
|
256
215
|
return
|
|
216
|
+
store.mark_seen_dedup(dedup_key)
|
|
257
217
|
|
|
258
218
|
# Flatten text (handle block-kit attachments too)
|
|
259
219
|
text = event.get("text", "")
|
|
260
220
|
for att in event.get("attachments", []):
|
|
261
221
|
text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
|
|
262
222
|
text = text.strip()
|
|
263
|
-
|
|
264
|
-
if not text or not _looks_like_error(text):
|
|
223
|
+
if not text:
|
|
265
224
|
return
|
|
266
225
|
|
|
267
|
-
#
|
|
268
|
-
|
|
269
|
-
if
|
|
270
|
-
|
|
271
|
-
|
|
226
|
+
# Find the project this bot is registered to
|
|
227
|
+
bots = store.get_watched_bots()
|
|
228
|
+
bot_info = next((b for b in bots if b["bot_id"] == bot_id), None)
|
|
229
|
+
project_name = (bot_info or {}).get("project_name") or ""
|
|
230
|
+
|
|
231
|
+
# Resolve the project issues directory
|
|
232
|
+
workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
|
|
233
|
+
if project_name:
|
|
234
|
+
# workspace_dir is <sentinel_root>/workspace, project dirs are siblings
|
|
235
|
+
project_dirs = [
|
|
236
|
+
d for d in workspace.iterdir()
|
|
237
|
+
if d.is_dir() and d.name != "workspace"
|
|
238
|
+
and (d / "config" / "sentinel.properties").exists()
|
|
239
|
+
]
|
|
240
|
+
matched = next(
|
|
241
|
+
(d for d in project_dirs
|
|
242
|
+
if d.name == project_name or
|
|
243
|
+
_read_project_name_from_dir(d) == project_name),
|
|
244
|
+
None,
|
|
245
|
+
)
|
|
246
|
+
issues_dir = (matched / "issues") if matched else (_Path(".") / "issues")
|
|
247
|
+
else:
|
|
248
|
+
issues_dir = _Path(".") / "issues"
|
|
249
|
+
|
|
250
|
+
issues_dir.mkdir(parents=True, exist_ok=True)
|
|
272
251
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
issues_dir.mkdir(exist_ok=True)
|
|
276
|
-
fname = f"slack-watch-{_uuid.uuid4().hex[:8]}.txt"
|
|
252
|
+
uid = _uuid.uuid4().hex[:8]
|
|
253
|
+
fname = f"bot-{project_name or 'unknown'}-{uid}.txt"
|
|
277
254
|
content = (
|
|
278
255
|
f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
|
|
279
256
|
f"SLACK_TS: {ts}\n\n"
|
|
@@ -281,7 +258,7 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
|
281
258
|
)
|
|
282
259
|
(issues_dir / fname).write_text(content, encoding="utf-8")
|
|
283
260
|
_Path("SENTINEL_POLL_NOW").touch()
|
|
284
|
-
logger.info("Bot watcher queued issue from %s
|
|
261
|
+
logger.info("Bot watcher queued issue from %s → %s/%s", bot_id, issues_dir, fname)
|
|
285
262
|
|
|
286
263
|
# React with 👀 so the team knows Sentinel noticed it
|
|
287
264
|
if ts:
|
|
@@ -291,6 +268,21 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
|
291
268
|
pass # reactions:write scope may not be granted — non-fatal
|
|
292
269
|
|
|
293
270
|
|
|
271
|
+
def _read_project_name_from_dir(project_dir: _Path) -> str:
|
|
272
|
+
"""Read PROJECT_NAME from a project's sentinel.properties, or return dir name."""
|
|
273
|
+
props = project_dir / "config" / "sentinel.properties"
|
|
274
|
+
if not props.exists():
|
|
275
|
+
return project_dir.name
|
|
276
|
+
try:
|
|
277
|
+
for line in props.read_text(encoding="utf-8").splitlines():
|
|
278
|
+
line = line.strip()
|
|
279
|
+
if line.upper().startswith("PROJECT_NAME="):
|
|
280
|
+
return line.split("=", 1)[1].partition("#")[0].strip()
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
return project_dir.name
|
|
284
|
+
|
|
285
|
+
|
|
294
286
|
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
295
287
|
|
|
296
288
|
async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
@@ -328,7 +320,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
|
|
|
328
320
|
|
|
329
321
|
try:
|
|
330
322
|
reply, is_done = await handle_message(
|
|
331
|
-
message, session.history, cfg_loader, store
|
|
323
|
+
message, session.history, cfg_loader, store,
|
|
324
|
+
slack_client=client,
|
|
332
325
|
)
|
|
333
326
|
except Exception as e:
|
|
334
327
|
logger.exception("Sentinel Boss error: %s", e)
|
|
@@ -74,10 +74,11 @@ class StateStore:
|
|
|
74
74
|
def _migrate(self):
|
|
75
75
|
"""Add new columns to existing DBs without breaking old installs."""
|
|
76
76
|
migrations = [
|
|
77
|
-
("add_sentinel_marker",
|
|
78
|
-
("add_confirmed_at",
|
|
79
|
-
("add_fix_outcome",
|
|
80
|
-
("add_marker_seen_at",
|
|
77
|
+
("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
|
|
78
|
+
("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
|
|
79
|
+
("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
|
|
80
|
+
("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
|
|
81
|
+
("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
|
|
81
82
|
]
|
|
82
83
|
with self._conn() as conn:
|
|
83
84
|
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
@@ -299,3 +300,42 @@ class StateStore:
|
|
|
299
300
|
"INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
|
|
300
301
|
(key, _now()),
|
|
301
302
|
)
|
|
303
|
+
|
|
304
|
+
# ── Watched bots (Slack passive monitor) ─────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def _ensure_watched_bots_table(self, conn):
|
|
307
|
+
conn.execute(
|
|
308
|
+
"CREATE TABLE IF NOT EXISTS watched_bots "
|
|
309
|
+
"(bot_id TEXT PRIMARY KEY, bot_name TEXT, added_by TEXT, added_at TEXT)"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def add_watched_bot(self, bot_id: str, bot_name: str, added_by: str = "config", project_name: str = ""):
|
|
313
|
+
with self._conn() as conn:
|
|
314
|
+
self._ensure_watched_bots_table(conn)
|
|
315
|
+
conn.execute(
|
|
316
|
+
"INSERT OR REPLACE INTO watched_bots (bot_id, bot_name, added_by, added_at, project_name) "
|
|
317
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
318
|
+
(bot_id, bot_name, added_by, _now(), project_name or None),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def remove_watched_bot(self, bot_id: str) -> bool:
|
|
322
|
+
"""Returns True if a row was deleted."""
|
|
323
|
+
with self._conn() as conn:
|
|
324
|
+
self._ensure_watched_bots_table(conn)
|
|
325
|
+
cur = conn.execute("DELETE FROM watched_bots WHERE bot_id = ?", (bot_id,))
|
|
326
|
+
return cur.rowcount > 0
|
|
327
|
+
|
|
328
|
+
def is_watched_bot(self, bot_id: str) -> bool:
|
|
329
|
+
with self._conn() as conn:
|
|
330
|
+
self._ensure_watched_bots_table(conn)
|
|
331
|
+
return conn.execute(
|
|
332
|
+
"SELECT 1 FROM watched_bots WHERE bot_id = ?", (bot_id,)
|
|
333
|
+
).fetchone() is not None
|
|
334
|
+
|
|
335
|
+
def get_watched_bots(self) -> list[dict]:
|
|
336
|
+
with self._conn() as conn:
|
|
337
|
+
self._ensure_watched_bots_table(conn)
|
|
338
|
+
rows = conn.execute(
|
|
339
|
+
"SELECT * FROM watched_bots ORDER BY added_at"
|
|
340
|
+
).fetchall()
|
|
341
|
+
return [dict(r) for r in rows]
|
|
@@ -36,12 +36,9 @@ WORKSPACE_DIR=./workspace
|
|
|
36
36
|
# Note: requires conversations:read scope on the Slack App if using channel name
|
|
37
37
|
# SLACK_CHANNEL=devops-sentinel
|
|
38
38
|
|
|
39
|
-
# Passive bot watcher
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# Comma-separated list of
|
|
44
|
-
# SLACK_WATCH_CHANNELS=alerts-1881, errors-prod
|
|
45
|
-
# Optional: only watch messages from specific bot IDs (comma-separated Slack bot IDs).
|
|
46
|
-
# Omit to watch all bots in the channel.
|
|
39
|
+
# Passive bot watcher — seed the watch list on startup with known bot IDs.
|
|
40
|
+
# Sentinel will passively queue every message from these bots as issues (no @mention needed).
|
|
41
|
+
# You can also add bots at runtime: "@Sentinel listen to @alertbot for project 1881"
|
|
42
|
+
# Use "@Sentinel list watched bots" to see the current list.
|
|
43
|
+
# Comma-separated list of Slack bot IDs (starts with B, not U).
|
|
47
44
|
# SLACK_WATCH_BOT_IDS=B12345678, B87654321
|