@misterhuydo/sentinel 1.4.49 → 1.4.51

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-25T11:16:59.180Z",
3
- "checkpoint_at": "2026-03-25T11:16:59.181Z",
2
+ "message": "Auto-checkpoint at 2026-03-25T11:34:55.696Z",
3
+ "checkpoint_at": "2026-03-25T11:34:55.697Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.49",
3
+ "version": "1.4.51",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -21,6 +21,13 @@ logger = logging.getLogger(__name__)
21
21
 
22
22
  GIT_TIMEOUT = 60
23
23
 
24
+
25
+ class MissingToolError(Exception):
26
+ """Raised when a required build tool (e.g. mvn, gradle) is not installed."""
27
+ def __init__(self, tool: str):
28
+ super().__init__(f"Build tool '{tool}' not found on this server")
29
+ self.tool = tool
30
+
24
31
  # Files that must never be modified by Sentinel
25
32
  _PROTECTED_PATHS = {".github/", "Jenkinsfile", "pom.xml"}
26
33
 
@@ -127,8 +134,7 @@ def _run_tests(repo: RepoConfig, local_path: str) -> bool:
127
134
  try:
128
135
  r = subprocess.run(cmd, cwd=local_path, capture_output=True, text=True, timeout=300)
129
136
  except FileNotFoundError:
130
- logger.warning("Test runner '%s' not found on this server — skipping tests for %s", cmd[0], repo.repo_name)
131
- return True
137
+ raise MissingToolError(cmd[0])
132
138
  if r.returncode != 0:
133
139
  logger.error("Tests failed:\n%s", r.stdout[-2000:] + r.stderr[-1000:])
134
140
  return False
@@ -22,14 +22,14 @@ from pathlib import Path
22
22
  from .cairn_client import ensure_installed as cairn_installed, index_repo
23
23
  from .config_loader import ConfigLoader, SentinelConfig
24
24
  from .fix_engine import generate_fix
25
- from .git_manager import apply_and_commit, publish, _git_env
25
+ from .git_manager import apply_and_commit, publish, _git_env, MissingToolError
26
26
  from .cicd_trigger import trigger as cicd_trigger
27
27
  from .log_fetcher import fetch_all
28
28
  from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
29
29
  from .issue_watcher import scan_issues, mark_done, IssueEvent
30
30
  from .repo_router import route
31
31
  from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification, send_upgrade_notification
32
- from .notify import notify_fix_blocked, notify_fix_applied
32
+ from .notify import notify_fix_blocked, notify_fix_applied, notify_missing_tool
33
33
  from .health_checker import evaluate_repos
34
34
  from .state_store import StateStore
35
35
 
@@ -106,7 +106,13 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
106
106
  })
107
107
  return
108
108
 
109
- commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
109
+ try:
110
+ commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
111
+ except MissingToolError as e:
112
+ logger.warning("Missing tool for %s: %s — notifying admin", event.source, e)
113
+ notify_missing_tool(sentinel, e.tool, repo.repo_name, event.source, "")
114
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
115
+ return
110
116
  if commit_status != "committed":
111
117
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
112
118
  send_failure_notification(sentinel, {
@@ -244,6 +250,14 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
244
250
  if repo.auto_publish:
245
251
  cicd_trigger(repo, store, event.fingerprint)
246
252
 
253
+ except MissingToolError as e:
254
+ logger.warning("Missing tool for %s: %s — notifying admin", event.source, e)
255
+ submitter_uid = getattr(event, "submitter_user_id", "")
256
+ notify_missing_tool(sentinel, e.tool, repo.repo_name, event.source, submitter_uid)
257
+ # Archive the issue — admin can re-raise it after installing the tool
258
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
259
+ mark_done(event.issue_file)
260
+
247
261
  except Exception:
248
262
  logger.exception("Unexpected error processing issue %s — archiving to prevent retry loop", event.source)
249
263
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
@@ -230,6 +230,34 @@ def notify_fix_blocked(
230
230
  logger.warning("notify_fix_blocked: email notification failed: %s", exc)
231
231
 
232
232
 
233
+ def notify_missing_tool(
234
+ cfg,
235
+ tool: str,
236
+ repo_name: str,
237
+ source: str,
238
+ submitter_user_id: str = "",
239
+ ) -> None:
240
+ """
241
+ Notify admins that a build tool is missing on this server.
242
+ Prompts them to ask Boss to install it.
243
+ """
244
+ repo_line = f" for *{repo_name}*" if repo_name else ""
245
+ slack_text = (
246
+ f":wrench: *Build tool missing{repo_line}*\n"
247
+ f"*Source:* {source}\n"
248
+ f"The fix was generated but tests couldn't run because `{tool}` is not installed on this server.\n\n"
249
+ f"Ask me to install it:\n"
250
+ f"> @Sentinel install {tool}\n\n"
251
+ f"Once installed, re-raise the issue to apply the fix."
252
+ )
253
+ if submitter_user_id:
254
+ if getattr(cfg, "slack_dm_submitter", True):
255
+ slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
256
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
257
+ else:
258
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
259
+
260
+
233
261
  def notify_fix_applied(
234
262
  cfg,
235
263
  source: str,
@@ -395,6 +395,30 @@ _TOOLS = [
395
395
  "required": ["fingerprint"],
396
396
  },
397
397
  },
398
+ {
399
+ "name": "retry_issue",
400
+ "description": (
401
+ "Re-queue a previously failed or blocked issue from the archive without requiring the "
402
+ "user to re-type the context. Scans issues/.done/ for the most recent matching file "
403
+ "and re-submits it to Sentinel. "
404
+ "Use when the user says things like: 'retry the last issue', 're-raise the umlaut fix', "
405
+ "'try that again', 'retry Whydah-TypeLib', 'run the last failed fix again'."
406
+ ),
407
+ "input_schema": {
408
+ "type": "object",
409
+ "properties": {
410
+ "project": {
411
+ "type": "string",
412
+ "description": "Project short name (e.g. '1881'). Required.",
413
+ },
414
+ "keyword": {
415
+ "type": "string",
416
+ "description": "Optional keyword to match against archived issue content (e.g. 'umlaut', 'Whydah-TypeLib')",
417
+ },
418
+ },
419
+ "required": ["project"],
420
+ },
421
+ },
398
422
  {
399
423
  "name": "list_pending_prs",
400
424
  "description": "List all open Sentinel PRs awaiting admin review.",
@@ -931,6 +955,25 @@ _TOOLS = [
931
955
  "required": ["repo_name"],
932
956
  },
933
957
  },
958
+ {
959
+ "name": "install_tool",
960
+ "description": (
961
+ "ADMIN ONLY. Install a missing build tool (e.g. maven, gradle, node) on this server "
962
+ "using Claude Code with shell execution rights. "
963
+ "Use after Sentinel reports a missing tool error. "
964
+ "e.g. 'install maven', 'install gradle', '@Sentinel install mvn'"
965
+ ),
966
+ "input_schema": {
967
+ "type": "object",
968
+ "properties": {
969
+ "tool_name": {
970
+ "type": "string",
971
+ "description": "The tool to install, e.g. 'maven', 'gradle', 'node'",
972
+ },
973
+ },
974
+ "required": ["tool_name"],
975
+ },
976
+ },
934
977
  {
935
978
  "name": "set_maintenance",
936
979
  "description": (
@@ -1228,6 +1271,65 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1228
1271
  "note": f"Delivered to '{project_label}'. Sentinel will process it on the next poll cycle.",
1229
1272
  })
1230
1273
 
1274
+ if name == "retry_issue":
1275
+ project_arg = inputs.get("project", "").strip()
1276
+ keyword = inputs.get("keyword", "").strip().lower()
1277
+
1278
+ project_dirs = _find_project_dirs(project_arg) if project_arg else _find_project_dirs()
1279
+ if not project_dirs:
1280
+ return json.dumps({"error": f"No project found matching '{project_arg}'"})
1281
+ if len(project_dirs) > 1 and project_arg:
1282
+ return json.dumps({"error": f"Ambiguous project '{project_arg}' — matches: {[_read_project_name(d) for d in project_dirs]}"})
1283
+
1284
+ project_dir = project_dirs[0]
1285
+ done_dir = project_dir / "issues" / ".done"
1286
+ if not done_dir.exists():
1287
+ return json.dumps({"error": "No archived issues found — issues/.done/ does not exist"})
1288
+
1289
+ # Find all archived issue files, newest first
1290
+ candidates = sorted(
1291
+ [f for f in done_dir.iterdir() if f.is_file() and not f.name.startswith(".")],
1292
+ key=lambda f: f.stat().st_mtime,
1293
+ reverse=True,
1294
+ )
1295
+ if not candidates:
1296
+ return json.dumps({"error": "No archived issues found in issues/.done/"})
1297
+
1298
+ # Filter by keyword if provided
1299
+ if keyword:
1300
+ matched = []
1301
+ for f in candidates:
1302
+ try:
1303
+ content = f.read_text(encoding="utf-8", errors="replace")
1304
+ if keyword in content.lower():
1305
+ matched.append(f)
1306
+ except OSError:
1307
+ pass
1308
+ if not matched:
1309
+ return json.dumps({"error": f"No archived issues match keyword '{keyword}'"})
1310
+ candidates = matched
1311
+
1312
+ source_file = candidates[0]
1313
+ content = source_file.read_text(encoding="utf-8", errors="replace")
1314
+
1315
+ # Re-submit as a fresh issue file (new name = new fingerprint = no cooldown block)
1316
+ issues_dir = project_dir / "issues"
1317
+ issues_dir.mkdir(exist_ok=True)
1318
+ fname = f"retry-{source_file.stem[-8:]}-{uuid.uuid4().hex[:6]}.txt"
1319
+ (issues_dir / fname).write_text(content, encoding="utf-8")
1320
+ (project_dir / "SENTINEL_POLL_NOW").touch()
1321
+
1322
+ project_label = _read_project_name(project_dir.resolve())
1323
+ logger.info("Boss retry_issue: re-queued '%s' as '%s' for %s", source_file.name, fname, project_label)
1324
+ return json.dumps({
1325
+ "status": "re-queued",
1326
+ "project": project_label,
1327
+ "original_file": source_file.name,
1328
+ "new_file": fname,
1329
+ "note": f"Re-submitted '{source_file.name}' to '{project_label}'. Poll triggered.",
1330
+ })
1331
+
1332
+
1231
1333
  if name == "get_fix_details":
1232
1334
  fp = inputs["fingerprint"]
1233
1335
  fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
@@ -2193,7 +2295,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2193
2295
  return json.dumps({"error": "cannot determine user — not clearing"})
2194
2296
 
2195
2297
  # ── Admin-only tools ──────────────────────────────────────────────────────
2196
- _ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr"}
2298
+ _ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool"}
2197
2299
  if name in _ADMIN_TOOLS:
2198
2300
  if not is_admin:
2199
2301
  return json.dumps({"error": "Admin access required. You are not in SLACK_ADMIN_USERS."})
@@ -2380,6 +2482,43 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2380
2482
  return json.dumps({"status": "error", "pr": pr_url,
2381
2483
  "error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
2382
2484
 
2485
+ if name == "install_tool":
2486
+ import subprocess as _sp
2487
+ tool_name = inputs.get("tool_name", "").strip()
2488
+ if not tool_name:
2489
+ return json.dumps({"error": "tool_name is required"})
2490
+
2491
+ bin_path = cfg_loader.sentinel.claude_code_bin
2492
+ api_key = cfg_loader.sentinel.anthropic_api_key
2493
+ env = {**_os.environ}
2494
+ if api_key:
2495
+ env["ANTHROPIC_API_KEY"] = api_key
2496
+
2497
+ prompt = (
2498
+ f"Install '{tool_name}' on this server so it can be used as a build/test tool. "
2499
+ f"Detect the OS and package manager (yum/dnf/apt), then run the appropriate install command. "
2500
+ f"After installing, verify the installation by running '{tool_name} --version' or the equivalent. "
2501
+ f"Report the installed version. Do not explain — just install and report."
2502
+ )
2503
+ logger.info("Boss install_tool: installing '%s' via Claude Code", tool_name)
2504
+ try:
2505
+ result = _sp.run(
2506
+ [bin_path, "--dangerously-skip-permissions", "--bare", "--print", prompt],
2507
+ capture_output=True, text=True, timeout=300, env=env,
2508
+ )
2509
+ output = ((result.stdout or "") + (result.stderr or "")).strip()
2510
+ success = result.returncode == 0
2511
+ logger.info("Boss install_tool: '%s' install %s:\n%s", tool_name, "OK" if success else "FAILED", output[-500:])
2512
+ return json.dumps({
2513
+ "status": "installed" if success else "failed",
2514
+ "tool": tool_name,
2515
+ "output": output[-1000:],
2516
+ })
2517
+ except FileNotFoundError:
2518
+ return json.dumps({"error": f"Claude Code binary not found at '{bin_path}'"})
2519
+ except _sp.TimeoutExpired:
2520
+ return json.dumps({"error": f"Install timed out for '{tool_name}'"})
2521
+
2383
2522
  return json.dumps({"error": f"unknown tool: {name}"})
2384
2523
 
2385
2524