@misterhuydo/sentinel 1.6.15 → 1.6.17
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/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.17"
|
package/python/sentinel/main.py
CHANGED
|
@@ -61,6 +61,42 @@ def _project_lock(project_name: str) -> "asyncio.Lock":
|
|
|
61
61
|
return _project_locks[project_name]
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def _is_paused(store: "StateStore", project_name: str, repo_name: str = "") -> tuple[bool, str]:
|
|
65
|
+
"""Return (paused, reason_text). Checks project-level then optional repo-level pause."""
|
|
66
|
+
if project_name and store.is_project_paused(project_name):
|
|
67
|
+
row = store.get_pause("project", project_name) or {}
|
|
68
|
+
rsn = row.get("reason") or "(no reason given)"
|
|
69
|
+
by = row.get("paused_by") or "admin"
|
|
70
|
+
return True, f"project `{project_name}` is paused by <@{by}> — {rsn}"
|
|
71
|
+
if repo_name and store.is_repo_paused(repo_name):
|
|
72
|
+
row = store.get_pause("repo", repo_name) or {}
|
|
73
|
+
rsn = row.get("reason") or "(no reason given)"
|
|
74
|
+
by = row.get("paused_by") or "admin"
|
|
75
|
+
return True, f"repo `{repo_name}` is paused by <@{by}> — {rsn}"
|
|
76
|
+
return False, ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _notify_skip(sentinel: SentinelConfig, event: "IssueEvent", reason: str) -> None:
|
|
80
|
+
"""Tell the submitter (and channel) when an issue is skipped via dedupe.
|
|
81
|
+
|
|
82
|
+
Without this, Boss's "I'll @-mention you when it completes" promise breaks —
|
|
83
|
+
the user is left waiting indefinitely after a silent skip.
|
|
84
|
+
"""
|
|
85
|
+
from .notify import slack_alert as _alert
|
|
86
|
+
submitter = getattr(event, "submitter_user_id", "")
|
|
87
|
+
channel = getattr(event, "origin_channel", "") or sentinel.slack_channel
|
|
88
|
+
if not channel or not sentinel.slack_bot_token:
|
|
89
|
+
return
|
|
90
|
+
mention = f"<@{submitter}> " if submitter else ""
|
|
91
|
+
_alert(
|
|
92
|
+
sentinel.slack_bot_token, channel,
|
|
93
|
+
f":fast_forward: {mention}*Issue skipped* — fingerprint `{event.fingerprint[:8]}`. "
|
|
94
|
+
f"{reason}. To force a new attempt, use `retry_issue` (it clears prior `failed` rows). "
|
|
95
|
+
f"If a successful fix already shipped for this fingerprint, the retry will still skip "
|
|
96
|
+
f"unless an admin clears the `applied` row.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
64
100
|
def _restart_via_execv() -> None:
|
|
65
101
|
"""Re-exec the current process, preserving the original `python -m sentinel.main` invocation.
|
|
66
102
|
|
|
@@ -213,6 +249,11 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
213
249
|
logger.debug("Error %s is dismissed — skipping", event.fingerprint)
|
|
214
250
|
return
|
|
215
251
|
|
|
252
|
+
# ── Project-level pause — silent skip; we'll re-detect on resume ────────────
|
|
253
|
+
if store.is_project_paused(sentinel.project_name or ""):
|
|
254
|
+
logger.debug("Error %s skipped — project paused", event.fingerprint[:8])
|
|
255
|
+
return
|
|
256
|
+
|
|
216
257
|
repo = route(event, cfg_loader.repos)
|
|
217
258
|
if not repo:
|
|
218
259
|
from .notify import slack_alert as _slack_alert
|
|
@@ -239,6 +280,11 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
239
280
|
logger.info("SENTINEL_PAUSE present — fix activity halted")
|
|
240
281
|
return
|
|
241
282
|
|
|
283
|
+
# ── Repo-level pause — silent skip; admin must resume to act on this ────────
|
|
284
|
+
if store.is_repo_paused(repo.repo_name):
|
|
285
|
+
logger.debug("Error %s skipped — repo %s paused", event.fingerprint[:8], repo.repo_name)
|
|
286
|
+
return
|
|
287
|
+
|
|
242
288
|
# ── Suppress repeat notifications for same error (notified within 4h) ───────
|
|
243
289
|
if store.was_notified_recently(event.fingerprint):
|
|
244
290
|
logger.debug("Error %s already notified recently — suppressing repeat", event.fingerprint[:8])
|
|
@@ -576,12 +622,21 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
576
622
|
logger.info("SENTINEL_PAUSE present -- fix activity halted")
|
|
577
623
|
return
|
|
578
624
|
|
|
625
|
+
# Project-level pause check (admin pause via Boss)
|
|
626
|
+
paused, prsn = _is_paused(store, sentinel.project_name or "")
|
|
627
|
+
if paused:
|
|
628
|
+
logger.info("Issue %s skipped — %s", event.source, prsn)
|
|
629
|
+
_notify_skip(sentinel, event, prsn)
|
|
630
|
+
mark_done(event.issue_file)
|
|
631
|
+
return
|
|
632
|
+
|
|
579
633
|
if store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
580
634
|
logger.info(
|
|
581
635
|
"Issue %s skipped — fingerprint %s attempted in last 24h "
|
|
582
636
|
"(use Boss `retry_issue` to clear the prior row and re-attempt)",
|
|
583
637
|
event.source, event.fingerprint,
|
|
584
638
|
)
|
|
639
|
+
_notify_skip(sentinel, event, "An attempt was already made in the last 24h")
|
|
585
640
|
mark_done(event.issue_file)
|
|
586
641
|
return
|
|
587
642
|
|
|
@@ -624,6 +679,14 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
624
679
|
mark_done(event.issue_file) # archive so it doesn't re-prompt every poll
|
|
625
680
|
return
|
|
626
681
|
|
|
682
|
+
# Repo-level pause check now that we know the target repo
|
|
683
|
+
paused, rrsn = _is_paused(store, "", repo.repo_name)
|
|
684
|
+
if paused:
|
|
685
|
+
logger.info("Issue %s skipped — %s", event.source, rrsn)
|
|
686
|
+
_notify_skip(sentinel, event, rrsn)
|
|
687
|
+
mark_done(event.issue_file)
|
|
688
|
+
return
|
|
689
|
+
|
|
627
690
|
# Per-project lock — serialise issue processing within a project so two
|
|
628
691
|
# concurrent claude sessions never race on the working tree of any repo.
|
|
629
692
|
async with _project_lock(sentinel.project_name or "_default"):
|
|
@@ -643,6 +706,7 @@ async def _handle_issue_locked(event, repo, cfg_loader, store):
|
|
|
643
706
|
"Issue %s skipped (post-lock recheck) — fingerprint %s already attempted",
|
|
644
707
|
event.source, event.fingerprint,
|
|
645
708
|
)
|
|
709
|
+
_notify_skip(sentinel, event, "Another worker already attempted this in the last 24h")
|
|
646
710
|
mark_done(event.issue_file)
|
|
647
711
|
return None
|
|
648
712
|
|
|
@@ -1103,15 +1167,32 @@ async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
|
|
|
1103
1167
|
cfg_loader.sentinel.workspace_dir, store=store,
|
|
1104
1168
|
)
|
|
1105
1169
|
)
|
|
1170
|
+
# If the project is paused, skip ALL health-checker actions silently —
|
|
1171
|
+
# we'll re-detect anything still wrong on resume.
|
|
1172
|
+
_project_paused_now = store.is_project_paused(cfg_loader.sentinel.project_name or "")
|
|
1106
1173
|
for hr in health_results:
|
|
1174
|
+
if _project_paused_now:
|
|
1175
|
+
continue
|
|
1176
|
+
# Repo-level pause: don't auto-fix or alert_once for paused repos
|
|
1177
|
+
# (recovered notifications still fire — they're just informational).
|
|
1178
|
+
if hr["action"] in ("fix", "alert_once") and store.is_repo_paused(hr["repo_name"]):
|
|
1179
|
+
logger.debug(
|
|
1180
|
+
"health_checker: %s action=%s skipped — repo paused",
|
|
1181
|
+
hr["repo_name"], hr["action"],
|
|
1182
|
+
)
|
|
1183
|
+
continue
|
|
1107
1184
|
if hr["action"] == "fix":
|
|
1108
1185
|
fp = f"health-{hr['repo_name']}"
|
|
1109
1186
|
store.record_error(fp, f"health_checker/{hr['repo_name']}", hr["message"])
|
|
1110
1187
|
if not store.fix_attempted_recently(fp, hours=6):
|
|
1111
1188
|
from .log_parser import ErrorEvent as _EE
|
|
1112
1189
|
from datetime import datetime, timezone as _tz
|
|
1190
|
+
# Use the repo name as the source so repo_router.route()
|
|
1191
|
+
# can map directly to the affected repo. The original
|
|
1192
|
+
# "health_checker/<repo>" provenance is preserved in
|
|
1193
|
+
# thread/logger_name and in the errors table audit row above.
|
|
1113
1194
|
synth = _EE(
|
|
1114
|
-
source=
|
|
1195
|
+
source=hr["repo_name"],
|
|
1115
1196
|
log_file="",
|
|
1116
1197
|
timestamp=datetime.now(_tz.utc).isoformat(),
|
|
1117
1198
|
level="ERROR",
|
|
@@ -1998,6 +1998,84 @@ _TOOLS = [
|
|
|
1998
1998
|
"required": ["repo_name"],
|
|
1999
1999
|
},
|
|
2000
2000
|
},
|
|
2001
|
+
{
|
|
2002
|
+
"name": "pause_project",
|
|
2003
|
+
"description": (
|
|
2004
|
+
"Halt ALL Sentinel fix activity for the current project until resume_project is called. "
|
|
2005
|
+
"Stops auto-fixes from logs, health-check auto-fixes, and Boss-submitted issues. Recovery "
|
|
2006
|
+
"notifications still fire. Admin-only. "
|
|
2007
|
+
"Use when: 'pause sentinel for elprint', 'stop working on this project', 'we're doing manual deploys today'."
|
|
2008
|
+
),
|
|
2009
|
+
"input_schema": {
|
|
2010
|
+
"type": "object",
|
|
2011
|
+
"properties": {
|
|
2012
|
+
"reason": {
|
|
2013
|
+
"type": "string",
|
|
2014
|
+
"description": "Optional reason — shown to anyone whose issue gets skipped while paused.",
|
|
2015
|
+
},
|
|
2016
|
+
},
|
|
2017
|
+
"required": [],
|
|
2018
|
+
},
|
|
2019
|
+
},
|
|
2020
|
+
{
|
|
2021
|
+
"name": "resume_project",
|
|
2022
|
+
"description": (
|
|
2023
|
+
"Lift a project pause set by pause_project. Sentinel resumes auto-fixing on the next poll. "
|
|
2024
|
+
"Admin-only. Use when: 'resume sentinel', 'unpause this project', 'we're back online'."
|
|
2025
|
+
),
|
|
2026
|
+
"input_schema": {
|
|
2027
|
+
"type": "object",
|
|
2028
|
+
"properties": {},
|
|
2029
|
+
"required": [],
|
|
2030
|
+
},
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
"name": "pause_repo",
|
|
2034
|
+
"description": (
|
|
2035
|
+
"Halt Sentinel fix activity for ONE repo. Other repos in the project keep working normally. "
|
|
2036
|
+
"Useful when one service is being manually rebuilt or its build is broken in CI. Admin-only. "
|
|
2037
|
+
"Use when: 'pause elprint-reporting-service', 'stop working on repo X for now'."
|
|
2038
|
+
),
|
|
2039
|
+
"input_schema": {
|
|
2040
|
+
"type": "object",
|
|
2041
|
+
"properties": {
|
|
2042
|
+
"repo_name": {
|
|
2043
|
+
"type": "string",
|
|
2044
|
+
"description": "Repo name as configured (from repo-configs/*.properties).",
|
|
2045
|
+
},
|
|
2046
|
+
"reason": {
|
|
2047
|
+
"type": "string",
|
|
2048
|
+
"description": "Optional reason — shown to anyone whose issue gets skipped while paused.",
|
|
2049
|
+
},
|
|
2050
|
+
},
|
|
2051
|
+
"required": ["repo_name"],
|
|
2052
|
+
},
|
|
2053
|
+
},
|
|
2054
|
+
{
|
|
2055
|
+
"name": "resume_repo",
|
|
2056
|
+
"description": (
|
|
2057
|
+
"Lift a repo pause set by pause_repo. Admin-only. "
|
|
2058
|
+
"Use when: 'resume elprint-reporting-service', 'unpause repo X'."
|
|
2059
|
+
),
|
|
2060
|
+
"input_schema": {
|
|
2061
|
+
"type": "object",
|
|
2062
|
+
"properties": {
|
|
2063
|
+
"repo_name": {
|
|
2064
|
+
"type": "string",
|
|
2065
|
+
"description": "Repo name to resume.",
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
"required": ["repo_name"],
|
|
2069
|
+
},
|
|
2070
|
+
},
|
|
2071
|
+
{
|
|
2072
|
+
"name": "list_paused",
|
|
2073
|
+
"description": (
|
|
2074
|
+
"List all current pauses (project-level and per-repo) with who paused them and why. "
|
|
2075
|
+
"Anyone in the allowed list can run this."
|
|
2076
|
+
),
|
|
2077
|
+
"input_schema": {"type": "object", "properties": {}, "required": []},
|
|
2078
|
+
},
|
|
2001
2079
|
{
|
|
2002
2080
|
"name": "chain_release",
|
|
2003
2081
|
"description": (
|
|
@@ -2285,6 +2363,28 @@ def _format_duration(seconds: int) -> str:
|
|
|
2285
2363
|
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
2286
2364
|
|
|
2287
2365
|
async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "", is_admin: bool = False) -> str:
|
|
2366
|
+
if name == "list_paused":
|
|
2367
|
+
pauses = store.list_pauses()
|
|
2368
|
+
if not pauses:
|
|
2369
|
+
return json.dumps({
|
|
2370
|
+
"total": 0,
|
|
2371
|
+
"pauses": [],
|
|
2372
|
+
"message": "No active pauses. Sentinel is fully operational.",
|
|
2373
|
+
})
|
|
2374
|
+
return json.dumps({
|
|
2375
|
+
"total": len(pauses),
|
|
2376
|
+
"pauses": [
|
|
2377
|
+
{
|
|
2378
|
+
"scope": p["scope_type"], # 'project' or 'repo'
|
|
2379
|
+
"name": p["scope_value"],
|
|
2380
|
+
"paused_by": p.get("paused_by") or "",
|
|
2381
|
+
"paused_at": (p.get("paused_at") or "")[:19],
|
|
2382
|
+
"reason": p.get("reason") or "",
|
|
2383
|
+
}
|
|
2384
|
+
for p in pauses
|
|
2385
|
+
],
|
|
2386
|
+
})
|
|
2387
|
+
|
|
2288
2388
|
if name == "get_status":
|
|
2289
2389
|
hours = int(inputs.get("hours", 24))
|
|
2290
2390
|
errors = store.get_recent_errors(hours)
|
|
@@ -2603,25 +2703,37 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2603
2703
|
if store:
|
|
2604
2704
|
try:
|
|
2605
2705
|
with store._conn() as _c:
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2706
|
+
# Check for a successful fix in the last 24h FIRST. We can't
|
|
2707
|
+
# use ORDER BY timestamp DESC LIMIT 1 because a spurious
|
|
2708
|
+
# 'failed' row inserted by a post-success exception (see
|
|
2709
|
+
# the catch-all in main.py) sorts ahead of the real
|
|
2710
|
+
# 'applied' row by milliseconds, hiding the success.
|
|
2711
|
+
_applied = _c.execute(
|
|
2712
|
+
"SELECT status, commit_hash FROM fixes "
|
|
2713
|
+
"WHERE fingerprint=? AND status IN ('applied', 'merged') "
|
|
2714
|
+
"AND timestamp >= datetime('now', '-24 hours') "
|
|
2715
|
+
"ORDER BY timestamp DESC LIMIT 1",
|
|
2609
2716
|
(_fp,),
|
|
2610
2717
|
).fetchone()
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
if _status in ("merged", "applied"):
|
|
2614
|
-
_commit = _row["commit_hash"] or ""
|
|
2718
|
+
if _applied:
|
|
2719
|
+
_commit = _applied["commit_hash"] or ""
|
|
2615
2720
|
return json.dumps({
|
|
2616
2721
|
"error": (
|
|
2617
2722
|
f"Already fixed — this issue was resolved "
|
|
2618
2723
|
+ (f"in commit `{_commit[:8]}`" if _commit else "successfully")
|
|
2619
|
-
+ f". Status: `{
|
|
2724
|
+
+ f". Status: `{_applied['status']}`. "
|
|
2620
2725
|
f"If the problem recurred, describe it as a new issue."
|
|
2621
2726
|
)
|
|
2622
2727
|
})
|
|
2623
|
-
|
|
2624
|
-
|
|
2728
|
+
_pending = _c.execute(
|
|
2729
|
+
"SELECT pr_url FROM fixes "
|
|
2730
|
+
"WHERE fingerprint=? AND status='pending' "
|
|
2731
|
+
"AND timestamp >= datetime('now', '-24 hours') "
|
|
2732
|
+
"ORDER BY timestamp DESC LIMIT 1",
|
|
2733
|
+
(_fp,),
|
|
2734
|
+
).fetchone()
|
|
2735
|
+
if _pending:
|
|
2736
|
+
_pr = _pending["pr_url"] or ""
|
|
2625
2737
|
return json.dumps({
|
|
2626
2738
|
"error": (
|
|
2627
2739
|
f"There is already an open PR for this issue"
|
|
@@ -4130,7 +4242,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
4130
4242
|
return json.dumps({"error": "cannot determine user — not clearing"})
|
|
4131
4243
|
|
|
4132
4244
|
# ── Admin-only tools ──────────────────────────────────────────────────────
|
|
4133
|
-
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool", "manage_release", "chain_release"}
|
|
4245
|
+
_ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool", "manage_release", "chain_release", "pause_project", "resume_project", "pause_repo", "resume_repo"}
|
|
4134
4246
|
if name in _ADMIN_TOOLS:
|
|
4135
4247
|
if not is_admin:
|
|
4136
4248
|
return json.dumps({"error": "This operation is admin-only (SLACK_ADMIN_USERS). Contact a Sentinel admin if you need access."})
|
|
@@ -4225,6 +4337,71 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
4225
4337
|
),
|
|
4226
4338
|
})
|
|
4227
4339
|
|
|
4340
|
+
if name == "pause_project":
|
|
4341
|
+
project_name = cfg_loader.sentinel.project_name or "_default"
|
|
4342
|
+
reason = (inputs.get("reason") or "").strip()
|
|
4343
|
+
store.pause_project(project_name, paused_by=user_id, reason=reason)
|
|
4344
|
+
logger.info("Boss: project %s paused by %s (reason: %s)", project_name, user_id, reason or "none")
|
|
4345
|
+
return json.dumps({
|
|
4346
|
+
"status": "paused",
|
|
4347
|
+
"project": project_name,
|
|
4348
|
+
"reason": reason or None,
|
|
4349
|
+
"message": (
|
|
4350
|
+
f"Project `{project_name}` is paused. Sentinel will skip all auto-fixes and Boss-submitted "
|
|
4351
|
+
f"issues until `resume_project` is called. Recovery notifications still fire."
|
|
4352
|
+
),
|
|
4353
|
+
})
|
|
4354
|
+
|
|
4355
|
+
if name == "resume_project":
|
|
4356
|
+
project_name = cfg_loader.sentinel.project_name or "_default"
|
|
4357
|
+
existed = store.resume_project(project_name)
|
|
4358
|
+
logger.info("Boss: project %s resume by %s (was_paused=%s)", project_name, user_id, existed)
|
|
4359
|
+
return json.dumps({
|
|
4360
|
+
"status": "resumed" if existed else "not_paused",
|
|
4361
|
+
"project": project_name,
|
|
4362
|
+
"message": (
|
|
4363
|
+
f"Project `{project_name}` is back online — Sentinel will resume on the next poll."
|
|
4364
|
+
if existed else
|
|
4365
|
+
f"Project `{project_name}` was not paused — nothing to do."
|
|
4366
|
+
),
|
|
4367
|
+
})
|
|
4368
|
+
|
|
4369
|
+
if name == "pause_repo":
|
|
4370
|
+
repo_name = (inputs.get("repo_name") or "").strip()
|
|
4371
|
+
reason = (inputs.get("reason") or "").strip()
|
|
4372
|
+
if not repo_name:
|
|
4373
|
+
return json.dumps({"error": "repo_name is required"})
|
|
4374
|
+
if repo_name not in cfg_loader.repos:
|
|
4375
|
+
return json.dumps({"error": f"Unknown repo `{repo_name}`. Configured repos: "
|
|
4376
|
+
f"{', '.join(sorted(cfg_loader.repos.keys()))}"})
|
|
4377
|
+
store.pause_repo(repo_name, paused_by=user_id, reason=reason)
|
|
4378
|
+
logger.info("Boss: repo %s paused by %s (reason: %s)", repo_name, user_id, reason or "none")
|
|
4379
|
+
return json.dumps({
|
|
4380
|
+
"status": "paused",
|
|
4381
|
+
"repo": repo_name,
|
|
4382
|
+
"reason": reason or None,
|
|
4383
|
+
"message": (
|
|
4384
|
+
f"Repo `{repo_name}` is paused. Other repos keep working normally. "
|
|
4385
|
+
f"Use `resume_repo` to lift the pause."
|
|
4386
|
+
),
|
|
4387
|
+
})
|
|
4388
|
+
|
|
4389
|
+
if name == "resume_repo":
|
|
4390
|
+
repo_name = (inputs.get("repo_name") or "").strip()
|
|
4391
|
+
if not repo_name:
|
|
4392
|
+
return json.dumps({"error": "repo_name is required"})
|
|
4393
|
+
existed = store.resume_repo(repo_name)
|
|
4394
|
+
logger.info("Boss: repo %s resume by %s (was_paused=%s)", repo_name, user_id, existed)
|
|
4395
|
+
return json.dumps({
|
|
4396
|
+
"status": "resumed" if existed else "not_paused",
|
|
4397
|
+
"repo": repo_name,
|
|
4398
|
+
"message": (
|
|
4399
|
+
f"Repo `{repo_name}` is back online."
|
|
4400
|
+
if existed else
|
|
4401
|
+
f"Repo `{repo_name}` was not paused — nothing to do."
|
|
4402
|
+
),
|
|
4403
|
+
})
|
|
4404
|
+
|
|
4228
4405
|
if name == "reset_fingerprint":
|
|
4229
4406
|
fp = inputs.get("fingerprint", "").strip()
|
|
4230
4407
|
if not fp:
|
|
@@ -111,6 +111,18 @@ class StateStore:
|
|
|
111
111
|
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
112
112
|
turn_count INTEGER NOT NULL DEFAULT 0
|
|
113
113
|
);
|
|
114
|
+
|
|
115
|
+
-- Admin-controlled pauses. scope_type is 'project' or 'repo';
|
|
116
|
+
-- scope_value is the project name or the repo name. While a row
|
|
117
|
+
-- exists, fix activity for that scope is halted.
|
|
118
|
+
CREATE TABLE IF NOT EXISTS pauses (
|
|
119
|
+
scope_type TEXT NOT NULL CHECK(scope_type IN ('project','repo')),
|
|
120
|
+
scope_value TEXT NOT NULL,
|
|
121
|
+
paused_by TEXT,
|
|
122
|
+
paused_at TEXT NOT NULL,
|
|
123
|
+
reason TEXT,
|
|
124
|
+
PRIMARY KEY (scope_type, scope_value)
|
|
125
|
+
);
|
|
114
126
|
""")
|
|
115
127
|
self._migrate()
|
|
116
128
|
logger.debug("StateStore initialised at %s", self.db_path)
|
|
@@ -942,6 +954,75 @@ class StateStore:
|
|
|
942
954
|
).fetchall()
|
|
943
955
|
return [dict(r) for r in rows]
|
|
944
956
|
|
|
957
|
+
# -- Admin pauses (project-wide and per-repo) ----------------------------------
|
|
958
|
+
|
|
959
|
+
def _set_pause(self, scope_type: str, scope_value: str, paused_by: str, reason: str) -> None:
|
|
960
|
+
if scope_type not in ("project", "repo"):
|
|
961
|
+
raise ValueError(f"scope_type must be 'project' or 'repo', got {scope_type!r}")
|
|
962
|
+
with self._conn() as conn:
|
|
963
|
+
conn.execute(
|
|
964
|
+
"INSERT OR REPLACE INTO pauses (scope_type, scope_value, paused_by, paused_at, reason) "
|
|
965
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
966
|
+
(scope_type, scope_value, paused_by, _now(), reason),
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
def _clear_pause(self, scope_type: str, scope_value: str) -> bool:
|
|
970
|
+
with self._conn() as conn:
|
|
971
|
+
cur = conn.execute(
|
|
972
|
+
"DELETE FROM pauses WHERE scope_type=? AND scope_value=?",
|
|
973
|
+
(scope_type, scope_value),
|
|
974
|
+
)
|
|
975
|
+
return cur.rowcount > 0
|
|
976
|
+
|
|
977
|
+
def pause_project(self, project_name: str, paused_by: str = "", reason: str = "") -> None:
|
|
978
|
+
"""Halt all fix activity for the named project until resume_project is called."""
|
|
979
|
+
self._set_pause("project", project_name, paused_by, reason)
|
|
980
|
+
|
|
981
|
+
def resume_project(self, project_name: str) -> bool:
|
|
982
|
+
"""Lift project pause. Returns True if a pause existed, False if no-op."""
|
|
983
|
+
return self._clear_pause("project", project_name)
|
|
984
|
+
|
|
985
|
+
def is_project_paused(self, project_name: str) -> bool:
|
|
986
|
+
with self._conn() as conn:
|
|
987
|
+
row = conn.execute(
|
|
988
|
+
"SELECT 1 FROM pauses WHERE scope_type='project' AND scope_value=?",
|
|
989
|
+
(project_name,),
|
|
990
|
+
).fetchone()
|
|
991
|
+
return row is not None
|
|
992
|
+
|
|
993
|
+
def pause_repo(self, repo_name: str, paused_by: str = "", reason: str = "") -> None:
|
|
994
|
+
"""Halt fix activity for one repo. Other repos in the project keep working."""
|
|
995
|
+
self._set_pause("repo", repo_name, paused_by, reason)
|
|
996
|
+
|
|
997
|
+
def resume_repo(self, repo_name: str) -> bool:
|
|
998
|
+
return self._clear_pause("repo", repo_name)
|
|
999
|
+
|
|
1000
|
+
def is_repo_paused(self, repo_name: str) -> bool:
|
|
1001
|
+
with self._conn() as conn:
|
|
1002
|
+
row = conn.execute(
|
|
1003
|
+
"SELECT 1 FROM pauses WHERE scope_type='repo' AND scope_value=?",
|
|
1004
|
+
(repo_name,),
|
|
1005
|
+
).fetchone()
|
|
1006
|
+
return row is not None
|
|
1007
|
+
|
|
1008
|
+
def get_pause(self, scope_type: str, scope_value: str) -> dict | None:
|
|
1009
|
+
"""Return the pause row (paused_by, paused_at, reason) or None."""
|
|
1010
|
+
with self._conn() as conn:
|
|
1011
|
+
row = conn.execute(
|
|
1012
|
+
"SELECT * FROM pauses WHERE scope_type=? AND scope_value=?",
|
|
1013
|
+
(scope_type, scope_value),
|
|
1014
|
+
).fetchone()
|
|
1015
|
+
return dict(row) if row else None
|
|
1016
|
+
|
|
1017
|
+
def list_pauses(self) -> list[dict]:
|
|
1018
|
+
"""Return all active pauses (project + repo) ordered by paused_at DESC."""
|
|
1019
|
+
with self._conn() as conn:
|
|
1020
|
+
rows = conn.execute(
|
|
1021
|
+
"SELECT scope_type, scope_value, paused_by, paused_at, reason "
|
|
1022
|
+
"FROM pauses ORDER BY paused_at DESC"
|
|
1023
|
+
).fetchall()
|
|
1024
|
+
return [dict(r) for r in rows]
|
|
1025
|
+
|
|
945
1026
|
def get_all_user_stats(self) -> list[dict]:
|
|
946
1027
|
"""Return activity summary for every known Slack user."""
|
|
947
1028
|
users = self.get_all_users() # {user_id: display_name}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Unit tests for project + repo pause API on StateStore."""
|
|
2
|
+
import pytest
|
|
3
|
+
from sentinel.state_store import StateStore
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def store(tmp_path):
|
|
8
|
+
return StateStore(str(tmp_path / "pauses.db"))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── project pause ────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def test_project_pause_default_unpaused(store):
|
|
14
|
+
assert store.is_project_paused("elprint") is False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_pause_project_then_check(store):
|
|
18
|
+
store.pause_project("elprint", paused_by="U123", reason="manual deploy")
|
|
19
|
+
assert store.is_project_paused("elprint") is True
|
|
20
|
+
row = store.get_pause("project", "elprint")
|
|
21
|
+
assert row["paused_by"] == "U123"
|
|
22
|
+
assert row["reason"] == "manual deploy"
|
|
23
|
+
assert row["paused_at"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_resume_project_clears_pause(store):
|
|
27
|
+
store.pause_project("elprint", paused_by="U1", reason="x")
|
|
28
|
+
assert store.resume_project("elprint") is True
|
|
29
|
+
assert store.is_project_paused("elprint") is False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_resume_project_returns_false_when_not_paused(store):
|
|
33
|
+
assert store.resume_project("elprint") is False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_pause_project_is_idempotent(store):
|
|
37
|
+
store.pause_project("elprint", paused_by="U1", reason="first")
|
|
38
|
+
store.pause_project("elprint", paused_by="U2", reason="second")
|
|
39
|
+
row = store.get_pause("project", "elprint")
|
|
40
|
+
assert row["paused_by"] == "U2"
|
|
41
|
+
assert row["reason"] == "second"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── repo pause ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def test_repo_pause_independent_of_project(store):
|
|
47
|
+
store.pause_repo("elprint-reporting-service", paused_by="U1", reason="broken build")
|
|
48
|
+
assert store.is_repo_paused("elprint-reporting-service") is True
|
|
49
|
+
assert store.is_project_paused("elprint") is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_pause_one_repo_doesnt_affect_others(store):
|
|
53
|
+
store.pause_repo("elprint-reporting-service", paused_by="U1", reason="x")
|
|
54
|
+
assert store.is_repo_paused("elprint-reporting-service") is True
|
|
55
|
+
assert store.is_repo_paused("elprint-component-service") is False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_resume_repo_clears_pause(store):
|
|
59
|
+
store.pause_repo("foo", paused_by="U1", reason="x")
|
|
60
|
+
assert store.resume_repo("foo") is True
|
|
61
|
+
assert store.is_repo_paused("foo") is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── list_pauses ──────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def test_list_pauses_empty(store):
|
|
67
|
+
assert store.list_pauses() == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_list_pauses_combined(store):
|
|
71
|
+
store.pause_project("elprint", paused_by="U1", reason="r1")
|
|
72
|
+
store.pause_repo("elprint-reporting-service", paused_by="U2", reason="r2")
|
|
73
|
+
pauses = store.list_pauses()
|
|
74
|
+
assert len(pauses) == 2
|
|
75
|
+
scopes = {p["scope_type"] for p in pauses}
|
|
76
|
+
assert scopes == {"project", "repo"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_invalid_scope_type_rejected(store):
|
|
80
|
+
with pytest.raises(ValueError):
|
|
81
|
+
store._set_pause("BOGUS", "x", "U1", "r")
|