@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.6.15",
3
+ "version": "1.6.17",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1 +1 @@
1
- __version__ = "1.6.15"
1
+ __version__ = "1.6.17"
@@ -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=f"health_checker/{hr['repo_name']}",
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
- _row = _c.execute(
2607
- "SELECT status, pr_url, commit_hash FROM fixes "
2608
- "WHERE fingerprint=? ORDER BY timestamp DESC LIMIT 1",
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
- if _row:
2612
- _status = _row["status"]
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: `{_status}`. "
2724
+ + f". Status: `{_applied['status']}`. "
2620
2725
  f"If the problem recurred, describe it as a new issue."
2621
2726
  )
2622
2727
  })
2623
- if _status == "pending":
2624
- _pr = _row["pr_url"] or ""
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")