@misterhuydo/sentinel 1.0.93 → 1.0.95
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/cairn_client.py +12 -4
- package/python/sentinel/config_loader.py +2 -0
- package/python/sentinel/sentinel_boss.py +170 -12
- package/python/sentinel/slack_bot.py +5 -2
- package/python/sentinel/state_store.py +39 -0
- package/templates/workspace-sentinel.properties +11 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-23T07:27:36.554Z
|
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-23T07:27:43.568Z",
|
|
3
|
+
"checkpoint_at": "2026-03-23T07:27:43.570Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -19,12 +19,20 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
CAIRN_BIN = "cairn"
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def get_version() -> str:
|
|
23
|
+
"""Return the installed cairn version string, or '' if not found."""
|
|
23
24
|
try:
|
|
24
|
-
subprocess.run([CAIRN_BIN, "--version"], capture_output=True, text=True, timeout=10)
|
|
25
|
-
return
|
|
25
|
+
r = subprocess.run([CAIRN_BIN, "--version"], capture_output=True, text=True, timeout=10)
|
|
26
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
26
27
|
except FileNotFoundError:
|
|
27
|
-
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_installed() -> bool:
|
|
32
|
+
version = get_version()
|
|
33
|
+
if version:
|
|
34
|
+
logger.info("cairn-mcp %s", version)
|
|
35
|
+
return True
|
|
28
36
|
logger.warning("cairn-mcp not found. Run: npm install -g @misterhuydo/cairn-mcp")
|
|
29
37
|
return False
|
|
30
38
|
|
|
@@ -62,6 +62,7 @@ class SentinelConfig:
|
|
|
62
62
|
slack_channel: str = "" # optional: restrict to one channel ID or name
|
|
63
63
|
slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
|
|
64
64
|
slack_allowed_users: list[str] = field(default_factory=list) # if set, only these Slack user IDs can talk to Boss
|
|
65
|
+
slack_admin_users: list[str] = field(default_factory=list) # subset of allowed users with admin privileges
|
|
65
66
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
66
67
|
# Auth strategy:
|
|
67
68
|
# ANTHROPIC_API_KEY — used by Sentinel Boss conversation (structured tools, cheap per-token)
|
|
@@ -162,6 +163,7 @@ class ConfigLoader:
|
|
|
162
163
|
c.slack_channel = d.get("SLACK_CHANNEL", "")
|
|
163
164
|
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
164
165
|
c.slack_allowed_users = _csv(d.get("SLACK_ALLOWED_USERS", ""))
|
|
166
|
+
c.slack_admin_users = _csv(d.get("SLACK_ADMIN_USERS", ""))
|
|
165
167
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
166
168
|
c.claude_pro_for_tasks = d.get("CLAUDE_PRO_FOR_TASKS", "true").lower() != "false"
|
|
167
169
|
self.sentinel = c
|
|
@@ -148,19 +148,26 @@ reply with a short summary grouped by category:
|
|
|
148
148
|
• `pull_repo` — git pull on managed application repos — "pull latest code"
|
|
149
149
|
• `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
|
|
150
150
|
|
|
151
|
+
*File sharing*
|
|
152
|
+
• `post_file` — upload a file to Slack — "give me that as a file", "export the log", "send me the diff"
|
|
153
|
+
|
|
154
|
+
*Personal*
|
|
155
|
+
• `my_stats` — your activity: issues submitted, fixes, conversation history — "my stats"
|
|
156
|
+
• `clear_my_history` — wipe your conversation history and start fresh — "clear my history"
|
|
157
|
+
|
|
151
158
|
*Slack bot watching*
|
|
152
|
-
• `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues — "listen to @alertbot"
|
|
153
|
-
• `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
|
|
154
159
|
• `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
|
|
155
160
|
|
|
156
|
-
*
|
|
161
|
+
*Admin* (SLACK_ADMIN_USERS if configured, otherwise all allowed users)
|
|
162
|
+
• `watch_bot` — register a Slack bot for passive monitoring; its messages become issues — "listen to @alertbot"
|
|
163
|
+
• `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
|
|
157
164
|
• `restart_project` — stop + restart a Sentinel monitoring instance (not the app) — "restart sentinel for 1881"
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
• `
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
• `
|
|
165
|
+
• `upgrade_sentinel` — pull latest Sentinel release and restart — "upgrade sentinel"
|
|
166
|
+
• `list_all_users` — all Slack users who have talked to Sentinel + activity summary
|
|
167
|
+
• `clear_user_history` — wipe a specific user's conversation history
|
|
168
|
+
• `reset_fingerprint` — clear the 24h fix lock so Sentinel retries an error
|
|
169
|
+
• `list_all_errors` — full unfiltered error database
|
|
170
|
+
• `export_db` — dump full Sentinel state as a downloadable file
|
|
164
171
|
|
|
165
172
|
Tone: direct, professional, like a senior engineer who owns the system.
|
|
166
173
|
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
@@ -588,6 +595,77 @@ _TOOLS = [
|
|
|
588
595
|
"required": ["content", "filename"],
|
|
589
596
|
},
|
|
590
597
|
},
|
|
598
|
+
{
|
|
599
|
+
"name": "list_all_users",
|
|
600
|
+
"description": (
|
|
601
|
+
"ADMIN ONLY. List all Slack users who have ever talked to Sentinel, "
|
|
602
|
+
"with their issue count and conversation message count. "
|
|
603
|
+
"e.g. 'list all users', 'who has talked to you?', 'show user activity'"
|
|
604
|
+
),
|
|
605
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
"name": "clear_user_history",
|
|
609
|
+
"description": (
|
|
610
|
+
"ADMIN ONLY. Clear the conversation history for a specific Slack user. "
|
|
611
|
+
"e.g. 'clear history for huy', 'reset bob's conversation'"
|
|
612
|
+
),
|
|
613
|
+
"input_schema": {
|
|
614
|
+
"type": "object",
|
|
615
|
+
"properties": {
|
|
616
|
+
"user_id": {
|
|
617
|
+
"type": "string",
|
|
618
|
+
"description": "Slack user ID to clear (e.g. U01AB2CD3EF)",
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
"required": ["user_id"],
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
"name": "reset_fingerprint",
|
|
626
|
+
"description": (
|
|
627
|
+
"ADMIN ONLY. Remove the 24h fix lock for an error fingerprint so Sentinel will retry it "
|
|
628
|
+
"on the next poll cycle. Use when a fix attempt failed and you want to force a retry. "
|
|
629
|
+
"e.g. 'retry fix abc123', 'reset fingerprint abc123de', 'let Sentinel try that error again'"
|
|
630
|
+
),
|
|
631
|
+
"input_schema": {
|
|
632
|
+
"type": "object",
|
|
633
|
+
"properties": {
|
|
634
|
+
"fingerprint": {
|
|
635
|
+
"type": "string",
|
|
636
|
+
"description": "Error fingerprint hash (8+ hex chars, from get_fix_details or list_all_errors)",
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
"required": ["fingerprint"],
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
"name": "list_all_errors",
|
|
644
|
+
"description": (
|
|
645
|
+
"ADMIN ONLY. Return the full unfiltered error database — all fingerprints, counts, "
|
|
646
|
+
"sources, and last-seen times. "
|
|
647
|
+
"e.g. 'show all errors', 'full error list', 'dump the error DB'"
|
|
648
|
+
),
|
|
649
|
+
"input_schema": {
|
|
650
|
+
"type": "object",
|
|
651
|
+
"properties": {
|
|
652
|
+
"hours": {
|
|
653
|
+
"type": "integer",
|
|
654
|
+
"description": "Limit to errors seen in the last N hours (0 = all time)",
|
|
655
|
+
"default": 0,
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
"name": "export_db",
|
|
662
|
+
"description": (
|
|
663
|
+
"ADMIN ONLY. Export the full Sentinel state (errors, fixes, PRs, users) as a "
|
|
664
|
+
"downloadable text file posted to Slack. "
|
|
665
|
+
"e.g. 'export the DB', 'download state', 'give me a full report file'"
|
|
666
|
+
),
|
|
667
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
668
|
+
},
|
|
591
669
|
]
|
|
592
670
|
|
|
593
671
|
|
|
@@ -654,7 +732,7 @@ def _git_pull(path: Path) -> dict:
|
|
|
654
732
|
|
|
655
733
|
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
656
734
|
|
|
657
|
-
async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "") -> str:
|
|
735
|
+
async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "", is_admin: bool = False) -> str:
|
|
658
736
|
if name == "get_status":
|
|
659
737
|
hours = int(inputs.get("hours", 24))
|
|
660
738
|
errors = store.get_recent_errors(hours)
|
|
@@ -999,6 +1077,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
999
1077
|
return json.dumps({"fetched": len(results), "results": results})
|
|
1000
1078
|
|
|
1001
1079
|
if name == "watch_bot":
|
|
1080
|
+
if not is_admin:
|
|
1081
|
+
return json.dumps({"error": "Admin access required to register bots for monitoring."})
|
|
1002
1082
|
user_ids = inputs.get("user_ids", [])
|
|
1003
1083
|
project_arg = inputs.get("project", "").strip()
|
|
1004
1084
|
if not user_ids:
|
|
@@ -1055,6 +1135,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1055
1135
|
return json.dumps({"results": results})
|
|
1056
1136
|
|
|
1057
1137
|
if name == "unwatch_bot":
|
|
1138
|
+
if not is_admin:
|
|
1139
|
+
return json.dumps({"error": "Admin access required to remove bots from monitoring."})
|
|
1058
1140
|
user_ids = inputs.get("user_ids", [])
|
|
1059
1141
|
if not user_ids:
|
|
1060
1142
|
return json.dumps({"error": "No user_ids provided"})
|
|
@@ -1082,6 +1164,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1082
1164
|
})
|
|
1083
1165
|
|
|
1084
1166
|
if name == "upgrade_sentinel":
|
|
1167
|
+
if not is_admin:
|
|
1168
|
+
return json.dumps({"error": "Admin access required to upgrade Sentinel."})
|
|
1085
1169
|
import threading
|
|
1086
1170
|
|
|
1087
1171
|
# Sentinel is installed via npm — use `sentinel upgrade` which handles
|
|
@@ -1183,6 +1267,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1183
1267
|
return json.dumps({"project": target, "repos_queried": len(results), "results": results})
|
|
1184
1268
|
|
|
1185
1269
|
if name == "restart_project":
|
|
1270
|
+
if not is_admin:
|
|
1271
|
+
return json.dumps({"error": "Admin access required to restart a project."})
|
|
1186
1272
|
project_arg = inputs.get("project", "").lower()
|
|
1187
1273
|
dirs = _find_project_dirs(project_arg)
|
|
1188
1274
|
if not dirs:
|
|
@@ -1340,6 +1426,72 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1340
1426
|
"note": "Your conversation history has been wiped. Next session starts fresh. [DONE]",
|
|
1341
1427
|
})
|
|
1342
1428
|
return json.dumps({"error": "cannot determine user — not clearing"})
|
|
1429
|
+
|
|
1430
|
+
# ── Admin-only tools ──────────────────────────────────────────────────────
|
|
1431
|
+
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db"}
|
|
1432
|
+
if name in _ADMIN_TOOLS:
|
|
1433
|
+
if not is_admin:
|
|
1434
|
+
return json.dumps({"error": "Admin access required. You are not in SLACK_ADMIN_USERS."})
|
|
1435
|
+
|
|
1436
|
+
if name == "list_all_users":
|
|
1437
|
+
stats = store.get_all_user_stats()
|
|
1438
|
+
return json.dumps({"users": stats, "total": len(stats)})
|
|
1439
|
+
|
|
1440
|
+
if name == "clear_user_history":
|
|
1441
|
+
target = inputs.get("target_user_id", "").strip()
|
|
1442
|
+
if not target:
|
|
1443
|
+
return json.dumps({"error": "target_user_id is required"})
|
|
1444
|
+
store.save_conversation(target, [])
|
|
1445
|
+
display = store.get_user_name(target)
|
|
1446
|
+
logger.info("Boss admin: cleared history for user %s (%s) by admin %s", target, display, user_id)
|
|
1447
|
+
return json.dumps({"status": "cleared", "target_user_id": target, "display_name": display})
|
|
1448
|
+
|
|
1449
|
+
if name == "reset_fingerprint":
|
|
1450
|
+
fp = inputs.get("fingerprint", "").strip()
|
|
1451
|
+
if not fp:
|
|
1452
|
+
return json.dumps({"error": "fingerprint is required"})
|
|
1453
|
+
found = store.reset_fingerprint(fp)
|
|
1454
|
+
logger.info("Boss admin: reset fingerprint %s by admin %s (found=%s)", fp, user_id, found)
|
|
1455
|
+
return json.dumps({"status": "reset" if found else "not_found", "fingerprint": fp,
|
|
1456
|
+
"note": "Sentinel will retry this error on the next poll." if found else "No fix record found for this fingerprint."})
|
|
1457
|
+
|
|
1458
|
+
if name == "list_all_errors":
|
|
1459
|
+
hours = int(inputs.get("hours", 0))
|
|
1460
|
+
errors = store.get_all_errors(hours)
|
|
1461
|
+
return json.dumps({"errors": errors[:100], "total": len(errors),
|
|
1462
|
+
"window_hours": hours or "all time"})
|
|
1463
|
+
|
|
1464
|
+
if name == "export_db":
|
|
1465
|
+
if not slack_client or not channel:
|
|
1466
|
+
return json.dumps({"error": "No Slack channel context — cannot upload file"})
|
|
1467
|
+
try:
|
|
1468
|
+
import sqlite3 as _sq
|
|
1469
|
+
import io as _io
|
|
1470
|
+
lines = []
|
|
1471
|
+
with _sq.connect(store.db_path) as _db:
|
|
1472
|
+
for tbl in ["errors", "fixes", "reports", "slack_users", "conversations", "submitted_issues"]:
|
|
1473
|
+
try:
|
|
1474
|
+
rows = _db.execute(f"SELECT * FROM {tbl}").fetchall() # noqa: S608
|
|
1475
|
+
cols = [d[0] for d in _db.execute(f"SELECT * FROM {tbl} LIMIT 0").description] # noqa: S608
|
|
1476
|
+
lines.append(f"=== {tbl} ({len(rows)} rows) ===")
|
|
1477
|
+
lines.append("\t".join(cols))
|
|
1478
|
+
for row in rows:
|
|
1479
|
+
lines.append("\t".join(str(v) if v is not None else "" for v in row))
|
|
1480
|
+
lines.append("")
|
|
1481
|
+
except Exception:
|
|
1482
|
+
lines.append(f"=== {tbl} (unavailable) ===\n")
|
|
1483
|
+
content = "\n".join(lines)
|
|
1484
|
+
await slack_client.files_upload_v2(
|
|
1485
|
+
channel=channel,
|
|
1486
|
+
content=content,
|
|
1487
|
+
filename="sentinel-db-export.tsv",
|
|
1488
|
+
title="Sentinel DB Export",
|
|
1489
|
+
)
|
|
1490
|
+
logger.info("Boss admin: exported DB (%d bytes) by admin %s", len(content), user_id)
|
|
1491
|
+
return json.dumps({"ok": True, "bytes": len(content)})
|
|
1492
|
+
except Exception as e:
|
|
1493
|
+
return json.dumps({"error": str(e)})
|
|
1494
|
+
|
|
1343
1495
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
1344
1496
|
|
|
1345
1497
|
|
|
@@ -1404,6 +1556,7 @@ async def _handle_with_cli(
|
|
|
1404
1556
|
user_name: str = "",
|
|
1405
1557
|
user_id: str = "",
|
|
1406
1558
|
attachments: list | None = None,
|
|
1559
|
+
is_admin: bool = False,
|
|
1407
1560
|
) -> tuple[str, bool]:
|
|
1408
1561
|
"""Fallback: use `claude --print` for users without an Anthropic API key."""
|
|
1409
1562
|
status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
|
|
@@ -1450,6 +1603,7 @@ async def _handle_with_cli(
|
|
|
1450
1603
|
+ f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
|
|
1451
1604
|
+ f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
|
|
1452
1605
|
+ (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
|
|
1606
|
+
+ f"\nAdmin access for this user: {'YES — admin tools are available' if is_admin else 'NO — admin tools will be refused'}"
|
|
1453
1607
|
+ f"\n\nCurrent status (last 24 h):\n{status_json}"
|
|
1454
1608
|
+ f"\n\nOpen PRs:\n{prs_json}"
|
|
1455
1609
|
+ (f"\n\nLog search results:\n{search_json}" if search_json else "")
|
|
@@ -1529,6 +1683,7 @@ async def _handle_with_api(
|
|
|
1529
1683
|
user_id: str = "",
|
|
1530
1684
|
attachments: list | None = None,
|
|
1531
1685
|
channel: str = "",
|
|
1686
|
+
is_admin: bool = False,
|
|
1532
1687
|
) -> tuple[str, bool]:
|
|
1533
1688
|
import anthropic
|
|
1534
1689
|
|
|
@@ -1554,6 +1709,7 @@ async def _handle_with_api(
|
|
|
1554
1709
|
+ f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
|
|
1555
1710
|
+ (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
|
|
1556
1711
|
+ (f"\nKnown projects in workspace: {', '.join(known_projects)}" if known_projects else "")
|
|
1712
|
+
+ f"\nAdmin access for this user: {'YES — admin tools are available' if is_admin else 'NO — admin tools will be refused'}"
|
|
1557
1713
|
)
|
|
1558
1714
|
|
|
1559
1715
|
# Build user content — include attachment blocks if any
|
|
@@ -1595,7 +1751,7 @@ async def _handle_with_api(
|
|
|
1595
1751
|
messages.append({"role": "assistant", "content": response.content})
|
|
1596
1752
|
tool_results = []
|
|
1597
1753
|
for tc in tool_blocks:
|
|
1598
|
-
result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client, user_id=user_id, channel=channel)
|
|
1754
|
+
result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client, user_id=user_id, channel=channel, is_admin=is_admin)
|
|
1599
1755
|
logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
|
|
1600
1756
|
tool_results.append({
|
|
1601
1757
|
"type": "tool_result",
|
|
@@ -1617,6 +1773,7 @@ async def handle_message(
|
|
|
1617
1773
|
user_id: str = "",
|
|
1618
1774
|
attachments: list | None = None,
|
|
1619
1775
|
channel: str = "",
|
|
1776
|
+
is_admin: bool = False,
|
|
1620
1777
|
) -> tuple[str, bool]:
|
|
1621
1778
|
"""
|
|
1622
1779
|
Process one user message through the Sentinel Boss (Claude with tool use).
|
|
@@ -1639,6 +1796,7 @@ async def handle_message(
|
|
|
1639
1796
|
return await _handle_with_api(
|
|
1640
1797
|
message, history, cfg_loader, store, slack_client=slack_client,
|
|
1641
1798
|
user_name=user_name, user_id=user_id, attachments=attachments, channel=channel,
|
|
1799
|
+
is_admin=is_admin,
|
|
1642
1800
|
)
|
|
1643
1801
|
except Exception as api_err:
|
|
1644
1802
|
err_str = str(api_err)
|
|
@@ -1653,7 +1811,7 @@ async def handle_message(
|
|
|
1653
1811
|
# 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
|
|
1654
1812
|
cli_reply, cli_done = await _handle_with_cli(
|
|
1655
1813
|
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
1656
|
-
user_id=user_id, attachments=attachments,
|
|
1814
|
+
user_id=user_id, attachments=attachments, is_admin=is_admin,
|
|
1657
1815
|
)
|
|
1658
1816
|
if not cli_reply.startswith(":warning:"):
|
|
1659
1817
|
return cli_reply, cli_done
|
|
@@ -344,6 +344,8 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
|
344
344
|
user_name = await _resolve_name(client, user_id)
|
|
345
345
|
store.upsert_user(user_id, user_name)
|
|
346
346
|
session = await _get_or_create_session(user_id, user_name, channel)
|
|
347
|
+
admin_users = cfg_loader.sentinel.slack_admin_users or []
|
|
348
|
+
is_admin = (not admin_users) or (user_id in admin_users)
|
|
347
349
|
|
|
348
350
|
if session.busy:
|
|
349
351
|
# Still processing a previous turn from this user — drop the duplicate
|
|
@@ -358,14 +360,14 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
|
358
360
|
if attachments:
|
|
359
361
|
logger.info("Boss: %d attachment(s) from %s", len(attachments), user_id)
|
|
360
362
|
|
|
361
|
-
await _run_turn(session, text, client, cfg_loader, store, attachments=attachments)
|
|
363
|
+
await _run_turn(session, text, client, cfg_loader, store, attachments=attachments, is_admin=is_admin)
|
|
362
364
|
|
|
363
365
|
|
|
364
366
|
# ── Turn processor ────────────────────────────────────────────────────────────
|
|
365
367
|
|
|
366
368
|
_MAX_HISTORY_TURNS = 20 # keep last 20 exchanges (~40 messages) to stay well within context limits
|
|
367
369
|
|
|
368
|
-
async def _run_turn(session: _Session, message: str, client, cfg_loader, store, attachments: list | None = None) -> None:
|
|
370
|
+
async def _run_turn(session: _Session, message: str, client, cfg_loader, store, attachments: list | None = None, is_admin: bool = False) -> None:
|
|
369
371
|
channel = session.channel
|
|
370
372
|
|
|
371
373
|
# Load persisted history from DB on the first turn of a new session
|
|
@@ -391,6 +393,7 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store,
|
|
|
391
393
|
user_id=session.user_id,
|
|
392
394
|
attachments=attachments or [],
|
|
393
395
|
channel=channel,
|
|
396
|
+
is_admin=is_admin,
|
|
394
397
|
)
|
|
395
398
|
except Exception as e:
|
|
396
399
|
logger.exception("Sentinel Boss error: %s", e)
|
|
@@ -458,3 +458,42 @@ class StateStore:
|
|
|
458
458
|
except Exception:
|
|
459
459
|
return []
|
|
460
460
|
return []
|
|
461
|
+
|
|
462
|
+
# ── Admin operations ──────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
def get_all_user_stats(self) -> list[dict]:
|
|
465
|
+
"""Return activity summary for every known Slack user."""
|
|
466
|
+
users = self.get_all_users() # {user_id: display_name}
|
|
467
|
+
result = []
|
|
468
|
+
for uid, name in users.items():
|
|
469
|
+
issues = self.get_submitted_issues(uid)
|
|
470
|
+
conv = self.load_conversation(uid)
|
|
471
|
+
result.append({
|
|
472
|
+
"user_id": uid,
|
|
473
|
+
"display_name": name,
|
|
474
|
+
"issues_submitted": len(issues),
|
|
475
|
+
"conversation_messages": len(conv),
|
|
476
|
+
})
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
def reset_fingerprint(self, fingerprint: str) -> bool:
|
|
480
|
+
"""Remove a fix record so Sentinel will retry the error on the next poll."""
|
|
481
|
+
with self._conn() as conn:
|
|
482
|
+
cur = conn.execute("DELETE FROM fixes WHERE fingerprint = ?", (fingerprint,))
|
|
483
|
+
conn.execute("UPDATE errors SET last_seen = NULL WHERE fingerprint = ?", (fingerprint,))
|
|
484
|
+
return cur.rowcount > 0
|
|
485
|
+
|
|
486
|
+
def get_all_errors(self, hours: int = 0) -> list[dict]:
|
|
487
|
+
"""Return full unfiltered error list, optionally within a time window."""
|
|
488
|
+
with self._conn() as conn:
|
|
489
|
+
if hours:
|
|
490
|
+
rows = conn.execute(
|
|
491
|
+
"SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
|
|
492
|
+
"ORDER BY last_seen DESC",
|
|
493
|
+
(f"-{hours}",),
|
|
494
|
+
).fetchall()
|
|
495
|
+
else:
|
|
496
|
+
rows = conn.execute(
|
|
497
|
+
"SELECT * FROM errors ORDER BY last_seen DESC"
|
|
498
|
+
).fetchall()
|
|
499
|
+
return [dict(r) for r in rows]
|
|
@@ -68,3 +68,14 @@ CONFIG_POLL_INTERVAL=60
|
|
|
68
68
|
# SLACK_BOT_TOKEN=xoxb-...
|
|
69
69
|
# SLACK_APP_TOKEN=xapp-...
|
|
70
70
|
# SLACK_CHANNEL=devops-sentinel
|
|
71
|
+
#
|
|
72
|
+
# Allowlist: if set, only these Slack user IDs can message Sentinel Boss.
|
|
73
|
+
# Leave blank to allow everyone in the workspace.
|
|
74
|
+
# Get user IDs from Slack: click profile → ⋯ → Copy member ID
|
|
75
|
+
# SLACK_ALLOWED_USERS=U123ABC,U456DEF
|
|
76
|
+
#
|
|
77
|
+
# Admin users: a subset of allowed users with elevated privileges.
|
|
78
|
+
# Admins can use: list_all_users, clear_user_history, reset_fingerprint,
|
|
79
|
+
# list_all_errors, export_db
|
|
80
|
+
# These are powerful operations — restrict to trusted team members.
|
|
81
|
+
# SLACK_ADMIN_USERS=U123ABC
|