@misterhuydo/sentinel 1.5.35 → 1.5.37

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.5.35",
3
+ "version": "1.5.37",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -115,6 +115,10 @@ fetch_from_properties() {
115
115
 
116
116
  # Apply defaults for fetch parameters
117
117
  TAIL="${TAIL:-$DEFAULT_TAIL}"
118
+ # Env var override takes precedence over config file value (used by Boss fetch_logs tool)
119
+ if [[ -n "$SENTINEL_GREP_FILTER_OVERRIDE" ]]; then
120
+ GREP_FILTER="$SENTINEL_GREP_FILTER_OVERRIDE"
121
+ fi
118
122
  if [[ -z "$GREP_FILTER" ]]; then
119
123
  GREP_FILTER="$DEFAULT_GREP_FILTER"
120
124
  elif [[ "$GREP_FILTER" == "none" || "$GREP_FILTER" == "*" ]]; then
@@ -1 +1 @@
1
- __version__ = "1.5.35"
1
+ __version__ = "1.5.37"
@@ -119,7 +119,7 @@ def _find_props_file(src):
119
119
  return None
120
120
 
121
121
 
122
- def _fetch_ssh(src, cfg):
122
+ def _fetch_ssh(src, cfg, grep_filter_override: str = ""):
123
123
  props_file = _find_props_file(src)
124
124
  if not props_file:
125
125
  raise FileNotFoundError(f"Properties file not found for {src.name}")
@@ -130,9 +130,13 @@ def _fetch_ssh(src, cfg):
130
130
  tmp_dir = workspace / f"_tmp_{src.name}"
131
131
  tmp_dir.mkdir(exist_ok=True)
132
132
 
133
+ env = {**os.environ.copy(), "OUTPUT_DIR": str(tmp_dir)}
134
+ if grep_filter_override:
135
+ env["SENTINEL_GREP_FILTER_OVERRIDE"] = grep_filter_override
136
+
133
137
  result = subprocess.run(
134
138
  ["bash", str(FETCH_LOG_SH), str(props_file)],
135
- env={**os.environ.copy(), "OUTPUT_DIR": str(tmp_dir)},
139
+ env=env,
136
140
  capture_output=True,
137
141
  text=True,
138
142
  timeout=120,
@@ -351,6 +351,12 @@ reply with a grouped summary like this:
351
351
  • `watch_bot` — register a bot for passive monitoring; its messages become issues
352
352
  • `unwatch_bot` — remove a bot from the watch list
353
353
  • `list_watched_bots` — show all monitored bots
354
+ • `save_service_alias` — remember that a short service name (e.g. "STS") maps to a repo name.
355
+ Call this when:
356
+ – The user replies to a routing question with a repo name
357
+ (e.g. Sentinel asked "which repo is STS?" and user replies "Whydah-SecurityTokenService")
358
+ – The user explicitly says "STS is Whydah-SecurityTokenService" or "route STS to ..."
359
+ After saving, Sentinel automatically reprocesses any pending routing for that service.
354
360
 
355
361
  *File sharing*
356
362
  • `post_file` — upload any output as a Slack file (logs, diffs, reports)
@@ -540,7 +546,13 @@ When to act vs. when to ask:
540
546
  state. Session memory is a snapshot — tasks complete, commits land, queues drain between turns.
541
547
  If you remember "task X was in-flight", check whether it finished before telling the user to wait.
542
548
  - Prefer filter_logs over search_logs when synced logs are available — it's instant and never causes session timeout.
543
- Use search_logs only when the user explicitly wants live/real-time data or synced logs are not yet available.
549
+ Use search_logs (live SSH fetch) when:
550
+ • The user explicitly wants live/real-time data
551
+ • Synced logs are not yet available
552
+ • filter_logs returns no results AND the context involves a recent deploy/release/commit
553
+ (synced logs may simply be stale — do NOT conclude the change isn't live yet; fetch live first)
554
+ When filter_logs returns no hits after a recent release, always retry with search_logs before
555
+ telling the user the log line isn't there.
544
556
  - If a tool call will take a moment (search, fetch, pull), prefix your reply with a brief "working" line ending in "..." before the results, e.g. "Searching SSOLWA for TryDig activity..." then the actual output.
545
557
  Never just say a working line and stop — always follow it with the results in the same message.
546
558
 
@@ -1220,6 +1232,31 @@ _TOOLS = [
1220
1232
  ),
1221
1233
  "input_schema": {"type": "object", "properties": {}},
1222
1234
  },
1235
+ {
1236
+ "name": "save_service_alias",
1237
+ "description": (
1238
+ "Save a mapping from a short service name to the full repo name so Sentinel can "
1239
+ "auto-route future bot alerts. Call this when: (1) the user replies to a routing "
1240
+ "question with a repo name (e.g. Sentinel asked 'which repo is STS?' and user "
1241
+ "replies 'Whydah-SecurityTokenService'), or (2) the user explicitly says "
1242
+ "'STS is Whydah-SecurityTokenService' or 'route STS alerts to ...'. "
1243
+ "After saving, also reprocess any pending routing for that service hint."
1244
+ ),
1245
+ "input_schema": {
1246
+ "type": "object",
1247
+ "properties": {
1248
+ "service_name": {
1249
+ "type": "string",
1250
+ "description": "The short name used in bot reports (e.g. 'STS', 'SSOLWA', 'UAS')",
1251
+ },
1252
+ "repo_name": {
1253
+ "type": "string",
1254
+ "description": "The full repo name to route this service to (must match a configured repo)",
1255
+ },
1256
+ },
1257
+ "required": ["service_name", "repo_name"],
1258
+ },
1259
+ },
1223
1260
  {
1224
1261
  "name": "upgrade_sentinel",
1225
1262
  "description": (
@@ -3038,7 +3075,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3038
3075
  if tail_override:
3039
3076
  env["TAIL"] = str(tail_override)
3040
3077
  if grep_override:
3041
- env["GREP_FILTER"] = grep_override
3078
+ env["SENTINEL_GREP_FILTER_OVERRIDE"] = grep_override
3042
3079
 
3043
3080
  cmd = ["bash", str(script)]
3044
3081
  if debug:
@@ -3164,6 +3201,56 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3164
3201
  ],
3165
3202
  })
3166
3203
 
3204
+ if name == "save_service_alias":
3205
+ service_name = inputs.get("service_name", "").strip()
3206
+ repo_name = inputs.get("repo_name", "").strip()
3207
+ if not service_name or not repo_name:
3208
+ return json.dumps({"error": "service_name and repo_name are required"})
3209
+
3210
+ # Validate repo_name against configured repos
3211
+ known_repos = list(cfg_loader.repos.keys())
3212
+ if repo_name not in cfg_loader.repos:
3213
+ # Try case-insensitive match
3214
+ matches = [r for r in known_repos if r.lower() == repo_name.lower()]
3215
+ if matches:
3216
+ repo_name = matches[0]
3217
+ else:
3218
+ return json.dumps({
3219
+ "error": f"'{repo_name}' does not match any configured repo.",
3220
+ "known_repos": known_repos,
3221
+ })
3222
+
3223
+ store.save_service_alias(service_name, repo_name, added_by=user_id or "boss")
3224
+
3225
+ # Reprocess pending routings for this service hint
3226
+ pending = store.resolve_pending_routing(service_name)
3227
+ requeued = 0
3228
+ if pending:
3229
+ for row in pending:
3230
+ idir = row.get("issues_dir") or str(Path(cfg_loader.sentinel.workspace_dir).parent / "issues")
3231
+ issues_dir = Path(idir)
3232
+ issues_dir.mkdir(parents=True, exist_ok=True)
3233
+ ts_str = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
3234
+ fp = str(uuid.uuid4())[:8]
3235
+ ifile = issues_dir / f"bot-{ts_str}-{fp}.txt"
3236
+ body = row.get("content", "")
3237
+ header = f"TARGET_REPO: {repo_name}\n"
3238
+ ifile.write_text(header + body, encoding="utf-8")
3239
+ requeued += 1
3240
+ logger.info("save_service_alias: requeued %d pending routings for %s → %s",
3241
+ requeued, service_name, repo_name)
3242
+
3243
+ return json.dumps({
3244
+ "status": "saved",
3245
+ "alias": f"{service_name} → {repo_name}",
3246
+ "requeued": requeued,
3247
+ "note": (
3248
+ f"Saved alias: '{service_name}' → '{repo_name}'. "
3249
+ + (f"Re-queued {requeued} pending issue(s) for processing." if requeued else
3250
+ "No pending issues to requeue.")
3251
+ ),
3252
+ })
3253
+
3167
3254
  if name == "upgrade_sentinel":
3168
3255
  if not is_admin:
3169
3256
  return json.dumps({"error": "upgrade is admin-only. Ask a Sentinel admin to perform the upgrade."})
@@ -181,28 +181,40 @@ import uuid as _uuid
181
181
  from pathlib import Path as _Path
182
182
 
183
183
 
184
- def _infer_target_repo(text: str, repos: dict) -> str:
184
+ def _infer_target_repo(text: str, repos: dict, store=None) -> str:
185
185
  """
186
186
  Try to infer a target repo from bot message content.
187
- Looks for 'Service: <name>' or 'service: <name>' patterns and fuzzy-matches
188
- the extracted name against known repo names. Returns the repo name on a unique
189
- match, empty string if ambiguous or no match found.
187
+ Checks in order:
188
+ 1. Saved aliases in DB (e.g. "STS" "Whydah-SecurityTokenService")
189
+ 2. Case-insensitive substring match against repo names
190
+ Returns the repo name on a unique match, empty string otherwise.
190
191
  """
191
192
  import re as _re
192
- # Extract service hints: "Service: STS", "service: SSOLWA", etc.
193
193
  hints = _re.findall(r'(?i)service:\s*(\S+)', text)
194
- # Also try class names: "Class: SomeService" — extract first word segment
195
194
  hints += _re.findall(r'(?i)class:\s*(\w+)', text)
196
195
 
197
196
  for hint in hints:
198
- hint_lower = hint.lower().rstrip(".,:")
199
- matches = [r for r in repos if hint_lower in r.lower()]
200
- if len(matches) == 1:
201
- return matches[0]
197
+ hint_clean = hint.rstrip(".,:")
198
+ # 1. Saved alias lookup
199
+ if store:
200
+ alias = store.get_service_alias(hint_clean)
201
+ if alias and alias in repos:
202
+ return alias
203
+ # 2. Case-insensitive substring match
204
+ sub_matches = [r for r in repos if hint_clean.lower() in r.lower()]
205
+ if len(sub_matches) == 1:
206
+ return sub_matches[0]
202
207
 
203
208
  return ""
204
209
 
205
210
 
211
+ def _extract_service_hint(text: str) -> str:
212
+ """Extract the first 'Service: <name>' value from a bot message."""
213
+ import re as _re
214
+ m = _re.search(r'(?i)service:\s*(\S+)', text)
215
+ return m.group(1).rstrip(".,: ") if m else ""
216
+
217
+
206
218
  async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
207
219
  """
208
220
  Called when a watched bot posts a message.
@@ -240,23 +252,40 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
240
252
 
241
253
  # Auto-detect target_repo from message content when not pre-configured
242
254
  if not target_repo and hasattr(cfg_loader, "repos") and cfg_loader.repos:
243
- target_repo = _infer_target_repo(text, cfg_loader.repos)
255
+ target_repo = _infer_target_repo(text, cfg_loader.repos, store=store)
244
256
  if not target_repo and len(cfg_loader.repos) > 1:
245
- # Can't determine — ask in the channel where the bot posted
257
+ # Can't determine — ask in the channel and store as pending routing
246
258
  bot_name = (bot_info or {}).get("bot_name") or bot_id
259
+ service_hint = _extract_service_hint(text)
260
+ hint_label = f"`{service_hint}`" if service_hint else "this alert"
247
261
  repo_names = ", ".join(f"`{r}`" for r in sorted(cfg_loader.repos))
248
262
  try:
249
263
  await client.chat_postMessage(
250
264
  channel=channel,
251
265
  text=(
252
- f":mag: I got an alert from *{bot_name}* but can't tell which repo to route it to.\n"
253
- f"Available repos: {repo_names}\n"
254
- f"Re-register with: `@Sentinel watch @{bot_name} for <repo-name>`"
266
+ f":mag: Got an alert from *{bot_name}* can't tell which repo to route {hint_label} to.\n"
267
+ f"Reply with the repo name and I'll remember it for next time.\n"
268
+ f"Available: {repo_names}\n\n"
269
+ f"_{text[:300]}_"
255
270
  ),
256
271
  )
257
272
  except Exception as _e:
258
273
  logger.warning("Bot watcher: could not post routing question: %s", _e)
259
- return # drop this message don't queue an unroutable issue
274
+ # Store as pending so Boss can reprocess when user replies
275
+ if service_hint:
276
+ try:
277
+ default_issues = str(_Path(cfg_loader.sentinel.workspace_dir).parent / "issues")
278
+ store.add_pending_routing(
279
+ service_hint=service_hint,
280
+ bot_id=bot_id,
281
+ channel=channel,
282
+ ts=ts,
283
+ content=text,
284
+ issues_dir=default_issues,
285
+ )
286
+ except Exception as _e:
287
+ logger.warning("Bot watcher: could not store pending routing: %s", _e)
288
+ return # drop this message until routing is resolved
260
289
 
261
290
  # Resolve the project issues directory
262
291
  workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
@@ -401,6 +401,81 @@ class StateStore:
401
401
  (key, _now()),
402
402
  )
403
403
 
404
+ # ── Service name aliases (e.g. "STS" → "Whydah-SecurityTokenService") ───────
405
+
406
+ def _ensure_service_aliases_table(self, conn):
407
+ conn.execute(
408
+ "CREATE TABLE IF NOT EXISTS service_aliases "
409
+ "(service_name TEXT PRIMARY KEY, repo_name TEXT, added_by TEXT, added_at TEXT)"
410
+ )
411
+
412
+ def save_service_alias(self, service_name: str, repo_name: str, added_by: str = "boss") -> None:
413
+ with self._conn() as conn:
414
+ self._ensure_service_aliases_table(conn)
415
+ conn.execute(
416
+ "INSERT OR REPLACE INTO service_aliases (service_name, repo_name, added_by, added_at) "
417
+ "VALUES (?, ?, ?, ?)",
418
+ (service_name.strip(), repo_name.strip(), added_by, _now()),
419
+ )
420
+
421
+ def get_service_alias(self, service_name: str) -> str:
422
+ with self._conn() as conn:
423
+ self._ensure_service_aliases_table(conn)
424
+ row = conn.execute(
425
+ "SELECT repo_name FROM service_aliases WHERE service_name = ? COLLATE NOCASE",
426
+ (service_name.strip(),),
427
+ ).fetchone()
428
+ return row["repo_name"] if row else ""
429
+
430
+ def get_all_service_aliases(self) -> list[dict]:
431
+ with self._conn() as conn:
432
+ self._ensure_service_aliases_table(conn)
433
+ rows = conn.execute("SELECT * FROM service_aliases ORDER BY service_name").fetchall()
434
+ return [dict(r) for r in rows]
435
+
436
+ # ── Pending bot-message routing questions ─────────────────────────────────
437
+
438
+ def _ensure_pending_routings_table(self, conn):
439
+ conn.execute(
440
+ "CREATE TABLE IF NOT EXISTS pending_routings "
441
+ "(id INTEGER PRIMARY KEY AUTOINCREMENT, service_hint TEXT, bot_id TEXT, "
442
+ "channel TEXT, ts TEXT, content TEXT, issues_dir TEXT, asked_at TEXT)"
443
+ )
444
+
445
+ def add_pending_routing(self, service_hint: str, bot_id: str, channel: str,
446
+ ts: str, content: str, issues_dir: str) -> None:
447
+ with self._conn() as conn:
448
+ self._ensure_pending_routings_table(conn)
449
+ conn.execute(
450
+ "INSERT INTO pending_routings "
451
+ "(service_hint, bot_id, channel, ts, content, issues_dir, asked_at) "
452
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
453
+ (service_hint, bot_id, channel, ts, content, issues_dir, _now()),
454
+ )
455
+
456
+ def get_pending_routings(self) -> list[dict]:
457
+ with self._conn() as conn:
458
+ self._ensure_pending_routings_table(conn)
459
+ rows = conn.execute(
460
+ "SELECT * FROM pending_routings ORDER BY asked_at DESC LIMIT 20"
461
+ ).fetchall()
462
+ return [dict(r) for r in rows]
463
+
464
+ def resolve_pending_routing(self, service_hint: str) -> list[dict]:
465
+ """Return and delete all pending routings for a given service hint."""
466
+ with self._conn() as conn:
467
+ self._ensure_pending_routings_table(conn)
468
+ rows = conn.execute(
469
+ "SELECT * FROM pending_routings WHERE service_hint = ? COLLATE NOCASE",
470
+ (service_hint,),
471
+ ).fetchall()
472
+ result = [dict(r) for r in rows]
473
+ conn.execute(
474
+ "DELETE FROM pending_routings WHERE service_hint = ? COLLATE NOCASE",
475
+ (service_hint,),
476
+ )
477
+ return result
478
+
404
479
  # ── Watched bots (Slack passive monitor) ─────────────────────────────────
405
480
 
406
481
  def _ensure_watched_bots_table(self, conn):