@misterhuydo/sentinel 1.5.4 → 1.5.6
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/minify-map.json +13 -1
- package/.cairn/session.json +2 -2
- package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
- package/.cairn/views/5f5141_main.py +1067 -0
- package/.cairn/views/62a614_bundle.js +4 -1
- package/.cairn/views/7802b9_cicd_trigger.py +171 -0
- package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
- package/lib/.cairn/minify-map.json +6 -0
- package/lib/.cairn/views/2a85cc_init.js +380 -0
- package/lib/.cairn/views/e26996_slack-setup.js +97 -0
- package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
- package/lib/.cairn/views/fc4a1a_add.js +164 -51
- package/lib/init.js +54 -0
- package/lib/maven.js +212 -0
- package/lib/slack-setup.js +5 -0
- package/package.json +1 -1
- package/python/requirements.txt +1 -0
- package/python/sentinel/.cairn/.cairn-project +0 -0
- package/python/sentinel/.cairn/.hint-lock +1 -0
- package/python/sentinel/.cairn/session.json +9 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/config_loader.py +29 -10
- package/python/sentinel/dependency_manager.py +9 -2
- package/python/sentinel/git_manager.py +23 -0
- package/python/sentinel/issue_watcher.py +7 -1
- package/python/sentinel/main.py +353 -8
- package/python/sentinel/notify.py +44 -12
- package/python/sentinel/repo_task_engine.py +49 -7
- package/python/sentinel/sentinel_boss.py +117 -3
- package/python/sentinel/slack_bot.py +15 -2
- package/python/sentinel/state_store.py +0 -1
- package/python/tests/__init__.py +0 -0
- package/python/tests/test_config_loader.py +138 -0
- package/python/tests/test_log_parser.py +62 -0
- package/python/tests/test_repo_router.py +73 -0
- package/python/tests/test_smoke.py +96 -0
- package/python/tests/test_state_store.py +128 -0
|
@@ -193,6 +193,35 @@ def slack_dm(bot_token: str, user_id: str, text: str) -> None:
|
|
|
193
193
|
logger.warning("slack_dm: failed to DM %s: %s", user_id, exc)
|
|
194
194
|
|
|
195
195
|
|
|
196
|
+
def notify_nexus_auth_failure(cfg, repo_name: str, context: str, mvn_output: str) -> None:
|
|
197
|
+
"""
|
|
198
|
+
DM the first admin user when a Maven build fails due to missing/invalid Nexus credentials.
|
|
199
|
+
Called from git_manager, dependency_manager, and sentinel_boss (chain_release) when
|
|
200
|
+
MavenAuthError is raised.
|
|
201
|
+
"""
|
|
202
|
+
if not cfg.slack_admin_users or not cfg.slack_bot_token:
|
|
203
|
+
logger.warning("Cannot DM admin about Nexus auth failure — no admin users or bot token configured")
|
|
204
|
+
return
|
|
205
|
+
snippet = mvn_output[-400:].strip() if mvn_output else ""
|
|
206
|
+
lines = [
|
|
207
|
+
f":lock: *Maven authentication failed* during `{context}` on `{repo_name}`",
|
|
208
|
+
"",
|
|
209
|
+
"Nexus credentials in `~/.m2/settings.xml` are missing or invalid.",
|
|
210
|
+
"",
|
|
211
|
+
"Fix it by sending me one of:",
|
|
212
|
+
"",
|
|
213
|
+
"*Option 1 — paste your settings.xml*",
|
|
214
|
+
"`nexus settings`",
|
|
215
|
+
"then the full XML content on the next line(s).",
|
|
216
|
+
"",
|
|
217
|
+
"*Option 2 — credentials per host*",
|
|
218
|
+
"`nexus creds <host> <username> <password>`",
|
|
219
|
+
]
|
|
220
|
+
if snippet:
|
|
221
|
+
lines += ["", f"```\n{snippet}\n```"]
|
|
222
|
+
slack_dm(cfg.slack_bot_token, cfg.slack_admin_users[0], "\n".join(lines))
|
|
223
|
+
|
|
224
|
+
|
|
196
225
|
def notify_fix_blocked(
|
|
197
226
|
cfg,
|
|
198
227
|
source: str,
|
|
@@ -200,13 +229,13 @@ def notify_fix_blocked(
|
|
|
200
229
|
reason: str,
|
|
201
230
|
repo_name: str = "",
|
|
202
231
|
submitter_user_id: str = "",
|
|
232
|
+
origin_channel: str = "",
|
|
203
233
|
) -> None:
|
|
204
234
|
"""
|
|
205
235
|
Notify that a fix needs human intervention.
|
|
206
236
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
- Always: email admins via reporter.send_failure_notification.
|
|
237
|
+
Posts to origin_channel if set (where the issue was raised), otherwise
|
|
238
|
+
falls back to cfg.slack_channel. Always emails admins.
|
|
210
239
|
"""
|
|
211
240
|
short_reason = (reason or "Claude could not determine a safe fix.")[:600]
|
|
212
241
|
repo_line = f"\n*Repo:* {repo_name}" if repo_name else ""
|
|
@@ -218,11 +247,11 @@ def notify_fix_blocked(
|
|
|
218
247
|
f"*Reason:*\n{short_reason}"
|
|
219
248
|
)
|
|
220
249
|
|
|
221
|
-
|
|
250
|
+
target_channel = origin_channel or cfg.slack_channel
|
|
222
251
|
if submitter_user_id:
|
|
223
|
-
slack_alert(cfg.slack_bot_token,
|
|
252
|
+
slack_alert(cfg.slack_bot_token, target_channel, f"<@{submitter_user_id}> {slack_text}")
|
|
224
253
|
else:
|
|
225
|
-
slack_alert(cfg.slack_bot_token,
|
|
254
|
+
slack_alert(cfg.slack_bot_token, target_channel, f"<!channel> {slack_text}")
|
|
226
255
|
|
|
227
256
|
# Always email admins
|
|
228
257
|
try:
|
|
@@ -260,6 +289,7 @@ def notify_missing_tool(
|
|
|
260
289
|
repo_name: str,
|
|
261
290
|
source: str,
|
|
262
291
|
submitter_user_id: str = "",
|
|
292
|
+
origin_channel: str = "",
|
|
263
293
|
) -> None:
|
|
264
294
|
"""
|
|
265
295
|
Notify admins that a build tool is missing on this server.
|
|
@@ -282,10 +312,11 @@ def notify_missing_tool(
|
|
|
282
312
|
f"*How to fix:*\n{steps}\n\n"
|
|
283
313
|
f"Then tell me: `retry {repo_name or source}`"
|
|
284
314
|
)
|
|
315
|
+
target_channel = origin_channel or cfg.slack_channel
|
|
285
316
|
if submitter_user_id:
|
|
286
|
-
slack_alert(cfg.slack_bot_token,
|
|
317
|
+
slack_alert(cfg.slack_bot_token, target_channel, f"<@{submitter_user_id}> {slack_text}")
|
|
287
318
|
else:
|
|
288
|
-
slack_alert(cfg.slack_bot_token,
|
|
319
|
+
slack_alert(cfg.slack_bot_token, target_channel, f"<!channel> {slack_text}")
|
|
289
320
|
|
|
290
321
|
|
|
291
322
|
def notify_fix_applied(
|
|
@@ -296,10 +327,11 @@ def notify_fix_applied(
|
|
|
296
327
|
branch: str,
|
|
297
328
|
pr_url: str,
|
|
298
329
|
submitter_user_id: str = "",
|
|
330
|
+
origin_channel: str = "",
|
|
299
331
|
) -> None:
|
|
300
332
|
"""
|
|
301
|
-
|
|
302
|
-
|
|
333
|
+
Notify that a fix was applied. Posts to origin_channel if set (where
|
|
334
|
+
the issue was raised), otherwise falls back to cfg.slack_channel.
|
|
303
335
|
"""
|
|
304
336
|
repo_line = f" in *{repo_name}*" if repo_name else ""
|
|
305
337
|
if pr_url:
|
|
@@ -315,9 +347,9 @@ def notify_fix_applied(
|
|
|
315
347
|
+ (f"{action_line}\n" if action_line else "")
|
|
316
348
|
).rstrip()
|
|
317
349
|
|
|
318
|
-
|
|
350
|
+
target_channel = origin_channel or cfg.slack_channel
|
|
319
351
|
channel_text = f"<@{submitter_user_id}> {slack_text}" if submitter_user_id else slack_text
|
|
320
|
-
slack_alert(cfg.slack_bot_token,
|
|
352
|
+
slack_alert(cfg.slack_bot_token, target_channel, channel_text)
|
|
321
353
|
|
|
322
354
|
|
|
323
355
|
def notify_cascade_started(
|
|
@@ -12,6 +12,7 @@ File format:
|
|
|
12
12
|
TYPE: feature|fix|refactor|chore
|
|
13
13
|
SUBMITTED_BY: <@UXXX> (UXXX)
|
|
14
14
|
SUBMITTED_AT: 2026-03-27T10:00:00+00:00
|
|
15
|
+
RUN_AT: 2026-03-28T02:00:00+00:00 # optional — task is held until this UTC time
|
|
15
16
|
NOTIFY: U1234567,U7654321 # optional
|
|
16
17
|
|
|
17
18
|
Full task description — what to implement, fix, or refactor.
|
|
@@ -33,7 +34,7 @@ from .git_manager import _git_env
|
|
|
33
34
|
|
|
34
35
|
logger = logging.getLogger(__name__)
|
|
35
36
|
|
|
36
|
-
_META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "NOTIFY:")
|
|
37
|
+
_META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "RUN_AT:", "NOTIFY:")
|
|
37
38
|
_TASK_TIMEOUT = 900 # 15 minutes
|
|
38
39
|
|
|
39
40
|
|
|
@@ -48,6 +49,7 @@ class RepoTask:
|
|
|
48
49
|
notify_user_ids: list = field(default_factory=list)
|
|
49
50
|
fingerprint: str = ""
|
|
50
51
|
timestamp: str = ""
|
|
52
|
+
run_at: datetime | None = None # UTC; task is held until this time if set
|
|
51
53
|
|
|
52
54
|
def __post_init__(self):
|
|
53
55
|
if not self.fingerprint:
|
|
@@ -289,12 +291,20 @@ def run_repo_task(
|
|
|
289
291
|
if repo.cicd_type:
|
|
290
292
|
try:
|
|
291
293
|
from .cicd_trigger import trigger as cicd_trigger
|
|
292
|
-
cicd_trigger(repo, None, task.fingerprint)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
on_progress
|
|
294
|
+
ok = cicd_trigger(repo, None, task.fingerprint)
|
|
295
|
+
if ok:
|
|
296
|
+
logger.info("Repo task: CI/CD triggered for %s (%s)", repo.repo_name, repo.cicd_type)
|
|
297
|
+
if on_progress:
|
|
298
|
+
on_progress(f":rocket: Release triggered via `{repo.cicd_type}`")
|
|
299
|
+
return "done", f"__cicd__{repo.cicd_type}"
|
|
300
|
+
else:
|
|
301
|
+
logger.warning("Repo task: CI/CD trigger failed for %s", repo.repo_name)
|
|
302
|
+
if on_progress:
|
|
303
|
+
on_progress(f":warning: CI/CD trigger failed for `{repo.cicd_type}` — check logs")
|
|
296
304
|
except Exception as exc:
|
|
297
305
|
logger.warning("Repo task: CI/CD trigger failed for %s: %s", repo.repo_name, exc)
|
|
306
|
+
if on_progress:
|
|
307
|
+
on_progress(f":warning: CI/CD trigger error — {exc}")
|
|
298
308
|
return "done", None
|
|
299
309
|
else:
|
|
300
310
|
branch = f"sentinel/task-{task.fingerprint[:8]}"
|
|
@@ -323,8 +333,13 @@ def drop_repo_task(
|
|
|
323
333
|
description: str,
|
|
324
334
|
submitter_user_id: str = "",
|
|
325
335
|
notify_user_ids: list | None = None,
|
|
336
|
+
run_at: datetime | None = None,
|
|
326
337
|
) -> Path:
|
|
327
|
-
"""Drop a repo task file into <project_dir>/repo-tasks/.
|
|
338
|
+
"""Drop a repo task file into <project_dir>/repo-tasks/.
|
|
339
|
+
|
|
340
|
+
If run_at (UTC-aware datetime) is given the task will be held by the poll
|
|
341
|
+
loop until that time has passed before executing.
|
|
342
|
+
"""
|
|
328
343
|
tasks_dir = project_dir / "repo-tasks"
|
|
329
344
|
tasks_dir.mkdir(exist_ok=True)
|
|
330
345
|
import uuid as _uuid
|
|
@@ -338,11 +353,16 @@ def drop_repo_task(
|
|
|
338
353
|
if submitter_user_id else "SUBMITTED_BY: system"),
|
|
339
354
|
f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
|
|
340
355
|
]
|
|
356
|
+
if run_at is not None:
|
|
357
|
+
lines.append(f"RUN_AT: {run_at.astimezone(timezone.utc).isoformat()}")
|
|
341
358
|
if notify_user_ids:
|
|
342
359
|
lines.append(f"NOTIFY: {','.join(notify_user_ids)}")
|
|
343
360
|
lines += ["", description]
|
|
344
361
|
fpath.write_text("\n".join(lines), encoding="utf-8")
|
|
345
|
-
|
|
362
|
+
if run_at:
|
|
363
|
+
logger.info("Dropped repo task (scheduled): %s — run_at=%s", fname, run_at.isoformat())
|
|
364
|
+
else:
|
|
365
|
+
logger.info("Dropped repo task: %s", fname)
|
|
346
366
|
return fpath
|
|
347
367
|
|
|
348
368
|
|
|
@@ -367,6 +387,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
367
387
|
task_type = "feature"
|
|
368
388
|
submitter_user_id = ""
|
|
369
389
|
notify_user_ids: list = []
|
|
390
|
+
run_at: datetime | None = None
|
|
370
391
|
body_start = 0
|
|
371
392
|
|
|
372
393
|
for i, line in enumerate(lines):
|
|
@@ -384,6 +405,13 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
384
405
|
if m:
|
|
385
406
|
submitter_user_id = m.group(1)
|
|
386
407
|
body_start = i + 1
|
|
408
|
+
elif upper.startswith("RUN_AT:"):
|
|
409
|
+
raw_ts = stripped[7:].strip()
|
|
410
|
+
try:
|
|
411
|
+
run_at = datetime.fromisoformat(raw_ts).astimezone(timezone.utc)
|
|
412
|
+
except ValueError:
|
|
413
|
+
logger.warning("Repo task %s: invalid RUN_AT value '%s' — ignoring", f.name, raw_ts)
|
|
414
|
+
body_start = i + 1
|
|
387
415
|
elif upper.startswith("NOTIFY:"):
|
|
388
416
|
notify_user_ids = [u.strip() for u in stripped[7:].split(",") if u.strip()]
|
|
389
417
|
body_start = i + 1
|
|
@@ -399,6 +427,19 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
399
427
|
logger.warning("Repo task %s has no REPO: header — skipping", f.name)
|
|
400
428
|
continue
|
|
401
429
|
|
|
430
|
+
# Hold scheduled tasks until their run_at time has passed
|
|
431
|
+
if run_at is not None:
|
|
432
|
+
now_utc = datetime.now(timezone.utc)
|
|
433
|
+
if now_utc < run_at:
|
|
434
|
+
remaining = run_at - now_utc
|
|
435
|
+
hours, rem = divmod(int(remaining.total_seconds()), 3600)
|
|
436
|
+
minutes = rem // 60
|
|
437
|
+
logger.debug(
|
|
438
|
+
"Repo task %s scheduled for %s — holding (%dh %dm remaining)",
|
|
439
|
+
f.name, run_at.isoformat(), hours, minutes,
|
|
440
|
+
)
|
|
441
|
+
continue
|
|
442
|
+
|
|
402
443
|
tasks.append(RepoTask(
|
|
403
444
|
task_file=f,
|
|
404
445
|
repo_name=repo_name,
|
|
@@ -407,6 +448,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
407
448
|
message=message,
|
|
408
449
|
submitter_user_id=submitter_user_id,
|
|
409
450
|
notify_user_ids=notify_user_ids,
|
|
451
|
+
run_at=run_at,
|
|
410
452
|
))
|
|
411
453
|
logger.info("Found repo task: %s → %s (type=%s)", f.name, repo_name, task_type)
|
|
412
454
|
|
|
@@ -814,12 +814,13 @@ _TOOLS = [
|
|
|
814
814
|
{
|
|
815
815
|
"name": "repo_task",
|
|
816
816
|
"description": (
|
|
817
|
-
"ADMIN ONLY. Submit a feature, fix, or
|
|
817
|
+
"ADMIN ONLY. Submit a feature, fix, refactor, or scheduled deploy task for a managed repo. "
|
|
818
818
|
"Claude Code will run against the repo's local clone, implement the change, "
|
|
819
819
|
"commit, and push (or open a PR if AUTO_PUBLISH=false). "
|
|
820
820
|
"ALWAYS gather a complete spec first — ask follow-up questions until unambiguous. "
|
|
821
821
|
"Use for: 'add X to elprint-connector-service', 'fix Y in cairn', "
|
|
822
|
-
"'refactor OrderService',
|
|
822
|
+
"'refactor OrderService', 'deploy UIB at 3 AM tomorrow', "
|
|
823
|
+
"any human-requested change to a managed repo — immediate or scheduled."
|
|
823
824
|
),
|
|
824
825
|
"input_schema": {
|
|
825
826
|
"type": "object",
|
|
@@ -843,6 +844,16 @@ _TOOLS = [
|
|
|
843
844
|
"items": {"type": "string"},
|
|
844
845
|
"description": "Extra Slack user IDs to ping on completion (besides the submitter).",
|
|
845
846
|
},
|
|
847
|
+
"run_at": {
|
|
848
|
+
"type": "string",
|
|
849
|
+
"description": (
|
|
850
|
+
"Optional. Schedule the task for a future time. "
|
|
851
|
+
"Accepts natural language (e.g. '3 AM tomorrow Norwegian time', "
|
|
852
|
+
"'kl 03:00 norsk tid', 'at 15:30 CET', 'in 2 hours') "
|
|
853
|
+
"or an ISO 8601 datetime string. "
|
|
854
|
+
"If omitted the task runs immediately on the next poll cycle."
|
|
855
|
+
),
|
|
856
|
+
},
|
|
846
857
|
},
|
|
847
858
|
"required": ["repo_name", "description"],
|
|
848
859
|
},
|
|
@@ -1628,6 +1639,79 @@ _TOOLS = [
|
|
|
1628
1639
|
]
|
|
1629
1640
|
|
|
1630
1641
|
|
|
1642
|
+
# ── Scheduling helpers ────────────────────────────────────────────────────────
|
|
1643
|
+
|
|
1644
|
+
def _parse_run_at(raw: str) -> tuple:
|
|
1645
|
+
"""Parse a natural-language or ISO 8601 time expression into a UTC-aware datetime.
|
|
1646
|
+
|
|
1647
|
+
Returns (datetime, None) on success, (None, error_str) on failure.
|
|
1648
|
+
|
|
1649
|
+
Understands:
|
|
1650
|
+
- ISO 8601 strings with or without timezone
|
|
1651
|
+
- "at HH:MM [TZ]" / "kl HH:MM [norsk tid|CET|CEST|Oslo]"
|
|
1652
|
+
- "in N hours/minutes"
|
|
1653
|
+
- "tomorrow" modifier before or after HH:MM
|
|
1654
|
+
- Norwegian timezone keywords → Europe/Oslo
|
|
1655
|
+
"""
|
|
1656
|
+
import re as _re
|
|
1657
|
+
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
|
1658
|
+
|
|
1659
|
+
_UTC = _tz.utc
|
|
1660
|
+
|
|
1661
|
+
# Build Oslo timezone — prefer zoneinfo (accurate DST), fall back to fixed offset
|
|
1662
|
+
try:
|
|
1663
|
+
import zoneinfo as _zi
|
|
1664
|
+
_OSLO = _zi.ZoneInfo("Europe/Oslo")
|
|
1665
|
+
except Exception:
|
|
1666
|
+
# tzdata not installed or unavailable; use fixed UTC+1 (CET, conservative)
|
|
1667
|
+
_OSLO = _tz(_td(hours=1)) # type: ignore[assignment]
|
|
1668
|
+
|
|
1669
|
+
raw_l = raw.lower().strip()
|
|
1670
|
+
|
|
1671
|
+
# ── ISO 8601 ───────────────────────────────────────────────────────────────
|
|
1672
|
+
try:
|
|
1673
|
+
parsed = _dt.fromisoformat(raw)
|
|
1674
|
+
if parsed.tzinfo is None:
|
|
1675
|
+
parsed = parsed.replace(tzinfo=_OSLO)
|
|
1676
|
+
return parsed.astimezone(_UTC), None
|
|
1677
|
+
except ValueError:
|
|
1678
|
+
pass
|
|
1679
|
+
|
|
1680
|
+
# ── "in N hours/minutes" ──────────────────────────────────────────────────
|
|
1681
|
+
m = _re.search(r'in\s+(\d+)\s*(hour|hr|minute|min)', raw_l)
|
|
1682
|
+
if m:
|
|
1683
|
+
n = int(m.group(1))
|
|
1684
|
+
unit = m.group(2)
|
|
1685
|
+
delta = _td(hours=n) if unit.startswith("h") else _td(minutes=n)
|
|
1686
|
+
return _dt.now(_UTC) + delta, None
|
|
1687
|
+
|
|
1688
|
+
# ── "at HH:MM" / "kl HH:MM" with optional TZ and "tomorrow" ──────────────
|
|
1689
|
+
m = _re.search(r'(?:kl\.?\s*|at\s+)?(\d{1,2})[:\.](\d{2})', raw_l)
|
|
1690
|
+
if m:
|
|
1691
|
+
hour, minute = int(m.group(1)), int(m.group(2))
|
|
1692
|
+
|
|
1693
|
+
tz_hint = raw_l
|
|
1694
|
+
if any(kw in tz_hint for kw in ("norsk", "oslo", "norway", "norwegian", "cet", "cest")):
|
|
1695
|
+
tz = _OSLO
|
|
1696
|
+
elif "utc" in tz_hint:
|
|
1697
|
+
tz = _UTC
|
|
1698
|
+
else:
|
|
1699
|
+
tz = _OSLO # default for this project
|
|
1700
|
+
|
|
1701
|
+
today = _dt.now(tz).date()
|
|
1702
|
+
tomorrow = today + _td(days=1)
|
|
1703
|
+
base_date = tomorrow if "tomorrow" in raw_l or "i morgen" in raw_l else today
|
|
1704
|
+
|
|
1705
|
+
candidate = _dt(base_date.year, base_date.month, base_date.day, hour, minute, tzinfo=tz)
|
|
1706
|
+
# If the time is already past today, roll to tomorrow automatically
|
|
1707
|
+
if candidate <= _dt.now(tz) and "tomorrow" not in raw_l and "i morgen" not in raw_l:
|
|
1708
|
+
candidate += _td(days=1)
|
|
1709
|
+
|
|
1710
|
+
return candidate.astimezone(_UTC), None
|
|
1711
|
+
|
|
1712
|
+
return None, f"Unrecognised time expression: '{raw}'"
|
|
1713
|
+
|
|
1714
|
+
|
|
1631
1715
|
# ── Workspace helpers ─────────────────────────────────────────────────────────
|
|
1632
1716
|
|
|
1633
1717
|
def _workspace_dir() -> Path:
|
|
@@ -2181,6 +2265,15 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2181
2265
|
if isinstance(notify_ids, list):
|
|
2182
2266
|
notify_ids = [u for u in notify_ids if u and u != user_id]
|
|
2183
2267
|
|
|
2268
|
+
# Parse optional scheduled time
|
|
2269
|
+
run_at_raw = (inputs.get("run_at") or "").strip()
|
|
2270
|
+
run_at_dt = None
|
|
2271
|
+
run_at_parse_error = None
|
|
2272
|
+
if run_at_raw:
|
|
2273
|
+
run_at_dt, run_at_parse_error = _parse_run_at(run_at_raw)
|
|
2274
|
+
if run_at_parse_error:
|
|
2275
|
+
return json.dumps({"error": f"Could not parse run_at: {run_at_parse_error}"})
|
|
2276
|
+
|
|
2184
2277
|
_project_dirs = _find_project_dirs()
|
|
2185
2278
|
if not _project_dirs:
|
|
2186
2279
|
return json.dumps({"error": "No project directory found."})
|
|
@@ -2193,8 +2286,29 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2193
2286
|
description=description,
|
|
2194
2287
|
submitter_user_id=user_id,
|
|
2195
2288
|
notify_user_ids=notify_ids,
|
|
2289
|
+
run_at=run_at_dt,
|
|
2290
|
+
)
|
|
2291
|
+
logger.info(
|
|
2292
|
+
"Boss repo_task: dropped %s for user %s (repo=%s, run_at=%s)",
|
|
2293
|
+
task_file.name, user_id, repo_name,
|
|
2294
|
+
run_at_dt.isoformat() if run_at_dt else "immediate",
|
|
2196
2295
|
)
|
|
2197
|
-
|
|
2296
|
+
if run_at_dt:
|
|
2297
|
+
import zoneinfo as _zi
|
|
2298
|
+
oslo = _zi.ZoneInfo("Europe/Oslo")
|
|
2299
|
+
local_str = run_at_dt.astimezone(oslo).strftime("%Y-%m-%d %H:%M %Z")
|
|
2300
|
+
return json.dumps({
|
|
2301
|
+
"status": "scheduled",
|
|
2302
|
+
"repo": repo_name,
|
|
2303
|
+
"task_type": task_type,
|
|
2304
|
+
"file": task_file.name,
|
|
2305
|
+
"run_at_utc": run_at_dt.isoformat(),
|
|
2306
|
+
"run_at_local": local_str,
|
|
2307
|
+
"note": (
|
|
2308
|
+
f"Task scheduled for `{repo_name}` at {local_str} — "
|
|
2309
|
+
"Sentinel will pick it up automatically when the time arrives."
|
|
2310
|
+
),
|
|
2311
|
+
})
|
|
2198
2312
|
return json.dumps({
|
|
2199
2313
|
"status": "queued",
|
|
2200
2314
|
"repo": repo_name,
|
|
@@ -60,6 +60,10 @@ async def _get_or_create_session(user_id: str, user_name: str, channel: str) ->
|
|
|
60
60
|
async with _sessions_lock:
|
|
61
61
|
if user_id not in _sessions:
|
|
62
62
|
_sessions[user_id] = _Session(user_id, user_name, channel)
|
|
63
|
+
else:
|
|
64
|
+
# Always update channel so replies go to wherever the current message came from,
|
|
65
|
+
# not the channel where the session was first created (e.g. a prior DM).
|
|
66
|
+
_sessions[user_id].channel = channel
|
|
63
67
|
return _sessions[user_id]
|
|
64
68
|
|
|
65
69
|
|
|
@@ -353,12 +357,21 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
|
353
357
|
if not text:
|
|
354
358
|
text = "hello"
|
|
355
359
|
|
|
356
|
-
# Allowlist check — if SLACK_ALLOWED_USERS is configured,
|
|
357
|
-
# Admins (SLACK_ADMIN_USERS) are always allowed regardless of SLACK_ALLOWED_USERS
|
|
360
|
+
# Allowlist check — if SLACK_ALLOWED_USERS is configured, only those users + admins may interact.
|
|
361
|
+
# Admins (SLACK_ADMIN_USERS) are always allowed regardless of SLACK_ALLOWED_USERS.
|
|
358
362
|
allowed = cfg_loader.sentinel.slack_allowed_users
|
|
359
363
|
admin_users = cfg_loader.sentinel.slack_admin_users or []
|
|
360
364
|
if allowed and user_id not in allowed and user_id not in admin_users:
|
|
361
365
|
logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
|
|
366
|
+
# For DMs: send a polite rejection so the user knows they're not authorized
|
|
367
|
+
if event.get("channel_type") == "im":
|
|
368
|
+
try:
|
|
369
|
+
await client.chat_postMessage(
|
|
370
|
+
channel=channel,
|
|
371
|
+
text="Sorry, you're not authorized to interact with Sentinel. Contact an admin to get access.",
|
|
372
|
+
)
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
362
375
|
return
|
|
363
376
|
|
|
364
377
|
user_name, user_tz = await _resolve_name(client, user_id)
|
|
@@ -540,7 +540,6 @@ class StateStore:
|
|
|
540
540
|
"""Remove a fix record so Sentinel will retry the error on the next poll."""
|
|
541
541
|
with self._conn() as conn:
|
|
542
542
|
cur = conn.execute("DELETE FROM fixes WHERE fingerprint = ?", (fingerprint,))
|
|
543
|
-
conn.execute("UPDATE errors SET last_seen = NULL WHERE fingerprint = ?", (fingerprint,))
|
|
544
543
|
return cur.rowcount > 0
|
|
545
544
|
|
|
546
545
|
def get_all_errors(self, hours: int = 0) -> list[dict]:
|
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for config_loader."""
|
|
2
|
+
|
|
3
|
+
import textwrap
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from sentinel.config_loader import ConfigLoader
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def config_dir(tmp_path):
|
|
12
|
+
(tmp_path / "log-configs").mkdir()
|
|
13
|
+
(tmp_path / "repo-configs").mkdir()
|
|
14
|
+
|
|
15
|
+
(tmp_path / "sentinel.properties").write_text(textwrap.dedent("""\
|
|
16
|
+
POLL_INTERVAL_SECONDS=60
|
|
17
|
+
SMTP_HOST=smtp.example.com
|
|
18
|
+
SMTP_PORT=465
|
|
19
|
+
SMTP_USER=bot@example.com
|
|
20
|
+
SMTP_PASSWORD=secret
|
|
21
|
+
MAILS=admin@example.com, ops@example.com
|
|
22
|
+
REPORT_INTERVAL_HOURS=4
|
|
23
|
+
STATE_DB=./test.db
|
|
24
|
+
WORKSPACE_DIR=./workspace
|
|
25
|
+
CLAUDE_CODE_BIN=claude
|
|
26
|
+
GITHUB_TOKEN=ghp_test
|
|
27
|
+
FIX_CONFIDENCE_THRESHOLD=0.8
|
|
28
|
+
"""))
|
|
29
|
+
|
|
30
|
+
(tmp_path / "log-configs" / "elprint-salescore.properties").write_text(textwrap.dedent("""\
|
|
31
|
+
SOURCE_TYPE=ssh
|
|
32
|
+
KEY=/path/to/key.pem
|
|
33
|
+
HOSTS=host1, host2
|
|
34
|
+
LOGS=logs/app.log, logs/alarm.log
|
|
35
|
+
REMOTE_SERVICE_USER=MyService
|
|
36
|
+
GREP_FILTER=ERROR|WARN
|
|
37
|
+
TAIL=200
|
|
38
|
+
"""))
|
|
39
|
+
|
|
40
|
+
(tmp_path / "log-configs" / "my-worker.properties").write_text(textwrap.dedent("""\
|
|
41
|
+
SOURCE_TYPE=cloudflare
|
|
42
|
+
CF_URL=https://worker.example.com/logs
|
|
43
|
+
CF_TOKEN=cf_token_abc
|
|
44
|
+
"""))
|
|
45
|
+
|
|
46
|
+
(tmp_path / "repo-configs" / "elprint-salescore.properties").write_text(textwrap.dedent("""\
|
|
47
|
+
REPO_URL=git@github.com:org/elprint-salescore.git
|
|
48
|
+
LOCAL_PATH=/repos/elprint-salescore
|
|
49
|
+
BRANCH=main
|
|
50
|
+
AUTO_PUBLISH=false
|
|
51
|
+
CICD_TYPE=jenkins
|
|
52
|
+
CICD_JOB_URL=https://jenkins.quadim.ai/job/elprint-salescore
|
|
53
|
+
CICD_TOKEN=tok123
|
|
54
|
+
"""))
|
|
55
|
+
|
|
56
|
+
# Library — no log-config counterpart
|
|
57
|
+
(tmp_path / "repo-configs" / "elprint-commons.properties").write_text(textwrap.dedent("""\
|
|
58
|
+
REPO_URL=git@github.com:org/elprint-commons.git
|
|
59
|
+
LOCAL_PATH=/repos/elprint-commons
|
|
60
|
+
BRANCH=main
|
|
61
|
+
AUTO_PUBLISH=false
|
|
62
|
+
CICD_TYPE=
|
|
63
|
+
CICD_JOB_URL=
|
|
64
|
+
CICD_TOKEN=
|
|
65
|
+
"""))
|
|
66
|
+
|
|
67
|
+
return tmp_path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_sentinel_config(config_dir):
|
|
71
|
+
loader = ConfigLoader(str(config_dir))
|
|
72
|
+
cfg = loader.sentinel
|
|
73
|
+
assert cfg.poll_interval_seconds == 60
|
|
74
|
+
assert cfg.smtp_host == "smtp.example.com"
|
|
75
|
+
assert cfg.mails == ["admin@example.com", "ops@example.com"]
|
|
76
|
+
assert cfg.fix_confidence_threshold == 0.8
|
|
77
|
+
assert cfg.github_token == "ghp_test"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_log_sources(config_dir):
|
|
81
|
+
loader = ConfigLoader(str(config_dir))
|
|
82
|
+
assert "elprint-salescore" in loader.log_sources
|
|
83
|
+
assert "my-worker" in loader.log_sources
|
|
84
|
+
|
|
85
|
+
ssh = loader.log_sources["elprint-salescore"]
|
|
86
|
+
assert ssh.source_type == "ssh"
|
|
87
|
+
assert ssh.hosts == ["host1", "host2"]
|
|
88
|
+
assert ssh.tail == 200
|
|
89
|
+
assert ssh.name == "elprint-salescore"
|
|
90
|
+
|
|
91
|
+
cf = loader.log_sources["my-worker"]
|
|
92
|
+
assert cf.source_type == "cloudflare"
|
|
93
|
+
assert cf.cf_url == "https://worker.example.com/logs"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_repos(config_dir):
|
|
97
|
+
loader = ConfigLoader(str(config_dir))
|
|
98
|
+
assert "elprint-salescore" in loader.repos
|
|
99
|
+
assert "elprint-commons" in loader.repos
|
|
100
|
+
|
|
101
|
+
r = loader.repos["elprint-salescore"]
|
|
102
|
+
assert r.repo_name == "elprint-salescore" # derived from stem
|
|
103
|
+
assert r.auto_publish is False
|
|
104
|
+
assert r.cicd_job_url == "https://jenkins.quadim.ai/job/elprint-salescore"
|
|
105
|
+
assert r.cicd_token == "tok123"
|
|
106
|
+
|
|
107
|
+
lib = loader.repos["elprint-commons"]
|
|
108
|
+
assert lib.cicd_job_url == "" # library, no CI/CD
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_stem_linking(config_dir):
|
|
112
|
+
"""Log-config and repo-config with same stem are linkable by key lookup."""
|
|
113
|
+
loader = ConfigLoader(str(config_dir))
|
|
114
|
+
# elprint-salescore exists in both — they link by stem
|
|
115
|
+
assert "elprint-salescore" in loader.log_sources
|
|
116
|
+
assert "elprint-salescore" in loader.repos
|
|
117
|
+
# elprint-commons has no log-config — that's fine
|
|
118
|
+
assert "elprint-commons" not in loader.log_sources
|
|
119
|
+
assert "elprint-commons" in loader.repos
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_inline_comments_stripped(config_dir):
|
|
123
|
+
(config_dir / "repo-configs" / "test-svc.properties").write_text(
|
|
124
|
+
"REPO_URL=git@github.com:org/test.git # comment\nLOCAL_PATH=/repos/test\n"
|
|
125
|
+
)
|
|
126
|
+
loader = ConfigLoader(str(config_dir))
|
|
127
|
+
assert loader.repos["test-svc"].repo_url == "git@github.com:org/test.git"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_no_redundant_fields(config_dir):
|
|
131
|
+
"""Confirm removed fields are not present on config objects."""
|
|
132
|
+
loader = ConfigLoader(str(config_dir))
|
|
133
|
+
repo = loader.repos["elprint-salescore"]
|
|
134
|
+
assert not hasattr(repo, "repo_name_field") # REPO_NAME property gone
|
|
135
|
+
assert not hasattr(repo, "package_prefixes") # PACKAGE_PREFIXES gone
|
|
136
|
+
assert not hasattr(repo, "cairn_mcp_enabled") # CAIRN_MCP_ENABLED gone
|
|
137
|
+
log = loader.log_sources["elprint-salescore"]
|
|
138
|
+
assert hasattr(log, "target_repo") # target_repo exists (used for routing)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tests for log_parser."""
|
|
2
|
+
|
|
3
|
+
import textwrap
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from sentinel.log_parser import parse_log_file, _fingerprint, _normalize_message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SAMPLE_LOG = textwrap.dedent("""\
|
|
12
|
+
2024-01-15 12:00:00.000 INFO [main] com.example.App - Starting application
|
|
13
|
+
2024-01-15 12:01:00.123 ERROR [http-nio-1] com.example.UserService - Failed to load user 42
|
|
14
|
+
\tat com.example.UserService.load(UserService.java:88)
|
|
15
|
+
\tat com.example.Controller.handle(Controller.java:55)
|
|
16
|
+
\t... 10 more
|
|
17
|
+
2024-01-15 12:02:00.456 WARN [scheduler] com.example.Job - Job took too long
|
|
18
|
+
2024-01-15 12:03:00.789 ERROR [http-nio-2] com.example.UserService - Failed to load user 99
|
|
19
|
+
\tat com.example.UserService.load(UserService.java:88)
|
|
20
|
+
\tat com.example.Controller.handle(Controller.java:55)
|
|
21
|
+
""")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def log_file(tmp_path):
|
|
26
|
+
p = tmp_path / "app.log"
|
|
27
|
+
p.write_text(SAMPLE_LOG)
|
|
28
|
+
return p
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_parse_events(log_file):
|
|
32
|
+
events = parse_log_file(log_file, "TEST")
|
|
33
|
+
# INFO is filtered out; we expect 2 ERRORs and 1 WARN
|
|
34
|
+
assert len(events) == 3
|
|
35
|
+
levels = {e.level for e in events}
|
|
36
|
+
assert "ERROR" in levels
|
|
37
|
+
assert "WARN" in levels
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_stack_trace_attached(log_file):
|
|
41
|
+
events = parse_log_file(log_file, "TEST")
|
|
42
|
+
errors = [e for e in events if e.level == "ERROR"]
|
|
43
|
+
assert all(len(e.stack_trace) > 0 for e in errors)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_dedup_fingerprint(log_file):
|
|
47
|
+
events = parse_log_file(log_file, "TEST")
|
|
48
|
+
errors = [e for e in events if e.level == "ERROR"]
|
|
49
|
+
# Both errors have the same message pattern and stack → same fingerprint
|
|
50
|
+
assert errors[0].fingerprint == errors[1].fingerprint
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_normalize_strips_ids():
|
|
54
|
+
msg = "Failed to load user 42 at 2024-01-15 12:00:00"
|
|
55
|
+
norm = _normalize_message(msg)
|
|
56
|
+
assert "42" not in norm
|
|
57
|
+
assert "TIMESTAMP" in norm
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_source_name_attached(log_file):
|
|
61
|
+
events = parse_log_file(log_file, "MYSOURCE")
|
|
62
|
+
assert all(e.source == "MYSOURCE" for e in events)
|