@misterhuydo/sentinel 1.0.92 → 1.0.94
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 +2 -0
- package/python/sentinel/sentinel_boss.py +177 -17
- 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-23T06:
|
|
1
|
+
2026-03-23T06:43:35.264Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-23T06:
|
|
3
|
-
"checkpoint_at": "2026-03-23T06:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-23T06:44:32.434Z",
|
|
3
|
+
"checkpoint_at": "2026-03-23T06:44:32.435Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -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
|
|
@@ -107,8 +107,9 @@ What you can do (tools available):
|
|
|
107
107
|
e.g. "what does the 1881 backend do?", "find PIN validation in elprint",
|
|
108
108
|
"any TODOs in cairn?", "are there security issues in elprint-sales?"
|
|
109
109
|
|
|
110
|
-
20. restart_project — Stop and restart a specific
|
|
111
|
-
|
|
110
|
+
20. restart_project — Stop and restart a specific Sentinel monitoring instance (stop.sh + start.sh).
|
|
111
|
+
This restarts the Sentinel agent for that project, NOT the application itself.
|
|
112
|
+
e.g. "restart sentinel for 1881", "restart the 1881 monitor", "reload elprint sentinel"
|
|
112
113
|
|
|
113
114
|
21. tail_log — Fetch the last N lines of a log source live, without a grep filter.
|
|
114
115
|
e.g. "show recent SSOLWA logs", "tail STS", "last 200 lines from 1881 logs"
|
|
@@ -147,19 +148,26 @@ reply with a short summary grouped by category:
|
|
|
147
148
|
• `pull_repo` — git pull on managed application repos — "pull latest code"
|
|
148
149
|
• `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
|
|
149
150
|
|
|
150
|
-
*
|
|
151
|
-
• `
|
|
152
|
-
• `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
|
|
153
|
-
• `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
|
|
151
|
+
*File sharing*
|
|
152
|
+
• `post_file` — upload a file to Slack — "give me that as a file", "export the log", "send me the diff"
|
|
154
153
|
|
|
155
|
-
*
|
|
156
|
-
• `
|
|
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
157
|
|
|
158
|
-
*
|
|
159
|
-
• `
|
|
158
|
+
*Slack bot watching*
|
|
159
|
+
• `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
|
|
160
160
|
|
|
161
|
-
*
|
|
162
|
-
• `
|
|
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"
|
|
164
|
+
• `restart_project` — stop + restart a Sentinel monitoring instance (not the app) — "restart sentinel for 1881"
|
|
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
|
|
163
171
|
|
|
164
172
|
Tone: direct, professional, like a senior engineer who owns the system.
|
|
165
173
|
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
@@ -488,8 +496,9 @@ _TOOLS = [
|
|
|
488
496
|
{
|
|
489
497
|
"name": "restart_project",
|
|
490
498
|
"description": (
|
|
491
|
-
"Stop and restart a specific Sentinel
|
|
492
|
-
"
|
|
499
|
+
"Stop and restart a specific Sentinel monitoring instance (runs stop.sh then start.sh). "
|
|
500
|
+
"This restarts the Sentinel agent process for that project — it does NOT restart the application itself. "
|
|
501
|
+
"Use when: 'restart sentinel for 1881', 'reload the 1881 monitor', 'restart elprint sentinel'. "
|
|
493
502
|
"Safer than restarting all projects at once."
|
|
494
503
|
),
|
|
495
504
|
"input_schema": {
|
|
@@ -586,6 +595,77 @@ _TOOLS = [
|
|
|
586
595
|
"required": ["content", "filename"],
|
|
587
596
|
},
|
|
588
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
|
+
},
|
|
589
669
|
]
|
|
590
670
|
|
|
591
671
|
|
|
@@ -652,7 +732,7 @@ def _git_pull(path: Path) -> dict:
|
|
|
652
732
|
|
|
653
733
|
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
654
734
|
|
|
655
|
-
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:
|
|
656
736
|
if name == "get_status":
|
|
657
737
|
hours = int(inputs.get("hours", 24))
|
|
658
738
|
errors = store.get_recent_errors(hours)
|
|
@@ -997,6 +1077,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
997
1077
|
return json.dumps({"fetched": len(results), "results": results})
|
|
998
1078
|
|
|
999
1079
|
if name == "watch_bot":
|
|
1080
|
+
if not is_admin:
|
|
1081
|
+
return json.dumps({"error": "Admin access required to register bots for monitoring."})
|
|
1000
1082
|
user_ids = inputs.get("user_ids", [])
|
|
1001
1083
|
project_arg = inputs.get("project", "").strip()
|
|
1002
1084
|
if not user_ids:
|
|
@@ -1053,6 +1135,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1053
1135
|
return json.dumps({"results": results})
|
|
1054
1136
|
|
|
1055
1137
|
if name == "unwatch_bot":
|
|
1138
|
+
if not is_admin:
|
|
1139
|
+
return json.dumps({"error": "Admin access required to remove bots from monitoring."})
|
|
1056
1140
|
user_ids = inputs.get("user_ids", [])
|
|
1057
1141
|
if not user_ids:
|
|
1058
1142
|
return json.dumps({"error": "No user_ids provided"})
|
|
@@ -1080,6 +1164,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1080
1164
|
})
|
|
1081
1165
|
|
|
1082
1166
|
if name == "upgrade_sentinel":
|
|
1167
|
+
if not is_admin:
|
|
1168
|
+
return json.dumps({"error": "Admin access required to upgrade Sentinel."})
|
|
1083
1169
|
import threading
|
|
1084
1170
|
|
|
1085
1171
|
# Sentinel is installed via npm — use `sentinel upgrade` which handles
|
|
@@ -1181,6 +1267,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1181
1267
|
return json.dumps({"project": target, "repos_queried": len(results), "results": results})
|
|
1182
1268
|
|
|
1183
1269
|
if name == "restart_project":
|
|
1270
|
+
if not is_admin:
|
|
1271
|
+
return json.dumps({"error": "Admin access required to restart a project."})
|
|
1184
1272
|
project_arg = inputs.get("project", "").lower()
|
|
1185
1273
|
dirs = _find_project_dirs(project_arg)
|
|
1186
1274
|
if not dirs:
|
|
@@ -1338,6 +1426,72 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1338
1426
|
"note": "Your conversation history has been wiped. Next session starts fresh. [DONE]",
|
|
1339
1427
|
})
|
|
1340
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
|
+
|
|
1341
1495
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
1342
1496
|
|
|
1343
1497
|
|
|
@@ -1402,6 +1556,7 @@ async def _handle_with_cli(
|
|
|
1402
1556
|
user_name: str = "",
|
|
1403
1557
|
user_id: str = "",
|
|
1404
1558
|
attachments: list | None = None,
|
|
1559
|
+
is_admin: bool = False,
|
|
1405
1560
|
) -> tuple[str, bool]:
|
|
1406
1561
|
"""Fallback: use `claude --print` for users without an Anthropic API key."""
|
|
1407
1562
|
status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
|
|
@@ -1448,6 +1603,7 @@ async def _handle_with_cli(
|
|
|
1448
1603
|
+ f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
|
|
1449
1604
|
+ f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
|
|
1450
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'}"
|
|
1451
1607
|
+ f"\n\nCurrent status (last 24 h):\n{status_json}"
|
|
1452
1608
|
+ f"\n\nOpen PRs:\n{prs_json}"
|
|
1453
1609
|
+ (f"\n\nLog search results:\n{search_json}" if search_json else "")
|
|
@@ -1527,6 +1683,7 @@ async def _handle_with_api(
|
|
|
1527
1683
|
user_id: str = "",
|
|
1528
1684
|
attachments: list | None = None,
|
|
1529
1685
|
channel: str = "",
|
|
1686
|
+
is_admin: bool = False,
|
|
1530
1687
|
) -> tuple[str, bool]:
|
|
1531
1688
|
import anthropic
|
|
1532
1689
|
|
|
@@ -1552,6 +1709,7 @@ async def _handle_with_api(
|
|
|
1552
1709
|
+ f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
|
|
1553
1710
|
+ (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
|
|
1554
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'}"
|
|
1555
1713
|
)
|
|
1556
1714
|
|
|
1557
1715
|
# Build user content — include attachment blocks if any
|
|
@@ -1593,7 +1751,7 @@ async def _handle_with_api(
|
|
|
1593
1751
|
messages.append({"role": "assistant", "content": response.content})
|
|
1594
1752
|
tool_results = []
|
|
1595
1753
|
for tc in tool_blocks:
|
|
1596
|
-
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)
|
|
1597
1755
|
logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
|
|
1598
1756
|
tool_results.append({
|
|
1599
1757
|
"type": "tool_result",
|
|
@@ -1615,6 +1773,7 @@ async def handle_message(
|
|
|
1615
1773
|
user_id: str = "",
|
|
1616
1774
|
attachments: list | None = None,
|
|
1617
1775
|
channel: str = "",
|
|
1776
|
+
is_admin: bool = False,
|
|
1618
1777
|
) -> tuple[str, bool]:
|
|
1619
1778
|
"""
|
|
1620
1779
|
Process one user message through the Sentinel Boss (Claude with tool use).
|
|
@@ -1637,6 +1796,7 @@ async def handle_message(
|
|
|
1637
1796
|
return await _handle_with_api(
|
|
1638
1797
|
message, history, cfg_loader, store, slack_client=slack_client,
|
|
1639
1798
|
user_name=user_name, user_id=user_id, attachments=attachments, channel=channel,
|
|
1799
|
+
is_admin=is_admin,
|
|
1640
1800
|
)
|
|
1641
1801
|
except Exception as api_err:
|
|
1642
1802
|
err_str = str(api_err)
|
|
@@ -1651,7 +1811,7 @@ async def handle_message(
|
|
|
1651
1811
|
# 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
|
|
1652
1812
|
cli_reply, cli_done = await _handle_with_cli(
|
|
1653
1813
|
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
1654
|
-
user_id=user_id, attachments=attachments,
|
|
1814
|
+
user_id=user_id, attachments=attachments, is_admin=is_admin,
|
|
1655
1815
|
)
|
|
1656
1816
|
if not cli_reply.startswith(":warning:"):
|
|
1657
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
|