@misterhuydo/sentinel 1.5.5 → 1.5.7
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 +7 -1
- package/.cairn/session.json +17 -5
- package/.cairn/views/23edf4_sentinel_boss.py +3664 -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 +346 -7
- package/python/sentinel/notify.py +44 -12
- package/python/sentinel/repo_task_engine.py +37 -3
- package/python/sentinel/sentinel_boss.py +213 -6
- 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:
|
|
@@ -331,8 +333,13 @@ def drop_repo_task(
|
|
|
331
333
|
description: str,
|
|
332
334
|
submitter_user_id: str = "",
|
|
333
335
|
notify_user_ids: list | None = None,
|
|
336
|
+
run_at: datetime | None = None,
|
|
334
337
|
) -> Path:
|
|
335
|
-
"""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
|
+
"""
|
|
336
343
|
tasks_dir = project_dir / "repo-tasks"
|
|
337
344
|
tasks_dir.mkdir(exist_ok=True)
|
|
338
345
|
import uuid as _uuid
|
|
@@ -346,11 +353,16 @@ def drop_repo_task(
|
|
|
346
353
|
if submitter_user_id else "SUBMITTED_BY: system"),
|
|
347
354
|
f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
|
|
348
355
|
]
|
|
356
|
+
if run_at is not None:
|
|
357
|
+
lines.append(f"RUN_AT: {run_at.astimezone(timezone.utc).isoformat()}")
|
|
349
358
|
if notify_user_ids:
|
|
350
359
|
lines.append(f"NOTIFY: {','.join(notify_user_ids)}")
|
|
351
360
|
lines += ["", description]
|
|
352
361
|
fpath.write_text("\n".join(lines), encoding="utf-8")
|
|
353
|
-
|
|
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)
|
|
354
366
|
return fpath
|
|
355
367
|
|
|
356
368
|
|
|
@@ -375,6 +387,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
375
387
|
task_type = "feature"
|
|
376
388
|
submitter_user_id = ""
|
|
377
389
|
notify_user_ids: list = []
|
|
390
|
+
run_at: datetime | None = None
|
|
378
391
|
body_start = 0
|
|
379
392
|
|
|
380
393
|
for i, line in enumerate(lines):
|
|
@@ -392,6 +405,13 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
392
405
|
if m:
|
|
393
406
|
submitter_user_id = m.group(1)
|
|
394
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
|
|
395
415
|
elif upper.startswith("NOTIFY:"):
|
|
396
416
|
notify_user_ids = [u.strip() for u in stripped[7:].split(",") if u.strip()]
|
|
397
417
|
body_start = i + 1
|
|
@@ -407,6 +427,19 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
407
427
|
logger.warning("Repo task %s has no REPO: header — skipping", f.name)
|
|
408
428
|
continue
|
|
409
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
|
+
|
|
410
443
|
tasks.append(RepoTask(
|
|
411
444
|
task_file=f,
|
|
412
445
|
repo_name=repo_name,
|
|
@@ -415,6 +448,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
|
415
448
|
message=message,
|
|
416
449
|
submitter_user_id=submitter_user_id,
|
|
417
450
|
notify_user_ids=notify_user_ids,
|
|
451
|
+
run_at=run_at,
|
|
418
452
|
))
|
|
419
453
|
logger.info("Found repo task: %s → %s (type=%s)", f.name, repo_name, task_type)
|
|
420
454
|
|
|
@@ -516,6 +516,8 @@ When to act vs. when to ask:
|
|
|
516
516
|
- Write/action tools (create_issue, trigger_poll, pull_repo, pause_sentinel, install_tool, merge_pr,
|
|
517
517
|
retry_issue, cancel_issue, post_file) → act immediately for clear commands; confirm only when intent is ambiguous
|
|
518
518
|
(e.g. unclear which project or repo to target).
|
|
519
|
+
- trigger_poll → trigger it, then IMMEDIATELY call get_status and report what is happening.
|
|
520
|
+
Never stop at "poll triggered" — the human wants to know the outcome, not that the trigger was sent.
|
|
519
521
|
- Explaining a tool ("what does X do?") → explain naturally, then offer to run it if relevant.
|
|
520
522
|
- NEVER gate investigation on user approval. If diagnosing a problem, run all relevant read tools
|
|
521
523
|
first, then present findings. Asking "Want me to look?" wastes a round trip.
|
|
@@ -578,7 +580,9 @@ Sentinel is a 24/7 autonomous agent. Act on clear signals without asking permiss
|
|
|
578
580
|
3. Populate `findings` with curated evidence — only when relevant and concise:
|
|
579
581
|
- Summarise: which services, how often, key pattern, 1-3 example lines. Max 500 words.
|
|
580
582
|
- Do NOT paste raw tool output.
|
|
581
|
-
4. After creating,
|
|
583
|
+
4. After creating, immediately call get_status and report: how many items are now in the queue,
|
|
584
|
+
what is already in progress, and when the next poll will pick things up.
|
|
585
|
+
Tell them they will get an @mention in this channel when the fix is applied or blocked.
|
|
582
586
|
|
|
583
587
|
Autonomous action policy — Sentinel acts, humans review outcomes:
|
|
584
588
|
- Read/investigate tools → always act immediately, no confirmation needed.
|
|
@@ -588,6 +592,35 @@ Autonomous action policy — Sentinel acts, humans review outcomes:
|
|
|
588
592
|
- pause_sentinel → confirm once (halts all monitoring).
|
|
589
593
|
- Everything else → use judgment. When in doubt, act and report what you did.
|
|
590
594
|
|
|
595
|
+
PROACTIVE COMMUNICATION — CRITICAL RULES:
|
|
596
|
+
You are a push-first agent. The engineer should NEVER have to ask for a status update
|
|
597
|
+
on something you already know about or can find out.
|
|
598
|
+
|
|
599
|
+
BANNED phrases (never say these):
|
|
600
|
+
- "Want me to check again in a few minutes?"
|
|
601
|
+
- "Should I check on that?"
|
|
602
|
+
- "I'll keep an eye on it."
|
|
603
|
+
- "I'll monitor that for you."
|
|
604
|
+
- "Let me know if you'd like an update."
|
|
605
|
+
- Anything that puts the follow-up burden back on the human.
|
|
606
|
+
|
|
607
|
+
Instead:
|
|
608
|
+
- If checking is warranted → check now, report the result in the same message.
|
|
609
|
+
- If something is running async (fix, task, poll) → tell the human EXACTLY what automated
|
|
610
|
+
notification they will receive and when. Example: "The main loop will post here when the
|
|
611
|
+
fix is applied or fails — usually within 2–3 minutes."
|
|
612
|
+
- After trigger_poll → wait for it, then call get_status immediately and report findings.
|
|
613
|
+
Do not stop at "poll triggered" — that is not an answer, it is a non-answer.
|
|
614
|
+
|
|
615
|
+
The main poll loop sends proactive Slack messages automatically when:
|
|
616
|
+
- A fix is applied (commit + PR opened)
|
|
617
|
+
- A fix is blocked (test failure, patch too large, etc.)
|
|
618
|
+
- A fix is confirmed (no recurrence for MARKER_CONFIRM_HOURS)
|
|
619
|
+
- A repo task or dev task completes or fails
|
|
620
|
+
- A health check recovers
|
|
621
|
+
You do NOT need to promise to monitor — the system already does it. Tell users this explicitly
|
|
622
|
+
when it is relevant, then end with [DONE].
|
|
623
|
+
|
|
591
624
|
When the engineer's request is fully handled, end your LAST message with the token: [DONE]
|
|
592
625
|
IMPORTANT: Always write your actual reply text FIRST, then append [DONE] at the end. Example: "Hello! I'm Sentinel. [DONE]". Never output [DONE] as your only content.
|
|
593
626
|
For greetings like "hello" or empty messages, introduce yourself briefly and offer help, then end with [DONE].
|
|
@@ -657,9 +690,11 @@ _TOOLS = [
|
|
|
657
690
|
{
|
|
658
691
|
"name": "get_status",
|
|
659
692
|
"description": (
|
|
660
|
-
"Get recent errors, fixes applied, fixes pending review,
|
|
693
|
+
"Get recent errors, fixes applied, fixes pending review, open PRs, "
|
|
694
|
+
"and the live task queue (issues and repo tasks waiting to be picked up). "
|
|
661
695
|
"Use for: 'what happened today?', 'any issues?', 'how are things?', "
|
|
662
|
-
"'what are the open PRs?', 'did sentinel fix anything?'"
|
|
696
|
+
"'what are the open PRs?', 'did sentinel fix anything?', "
|
|
697
|
+
"'what is queued?', 'what is pending?', 'progress on X?'"
|
|
663
698
|
),
|
|
664
699
|
"input_schema": {
|
|
665
700
|
"type": "object",
|
|
@@ -814,12 +849,13 @@ _TOOLS = [
|
|
|
814
849
|
{
|
|
815
850
|
"name": "repo_task",
|
|
816
851
|
"description": (
|
|
817
|
-
"ADMIN ONLY. Submit a feature, fix, or
|
|
852
|
+
"ADMIN ONLY. Submit a feature, fix, refactor, or scheduled deploy task for a managed repo. "
|
|
818
853
|
"Claude Code will run against the repo's local clone, implement the change, "
|
|
819
854
|
"commit, and push (or open a PR if AUTO_PUBLISH=false). "
|
|
820
855
|
"ALWAYS gather a complete spec first — ask follow-up questions until unambiguous. "
|
|
821
856
|
"Use for: 'add X to elprint-connector-service', 'fix Y in cairn', "
|
|
822
|
-
"'refactor OrderService',
|
|
857
|
+
"'refactor OrderService', 'deploy UIB at 3 AM tomorrow', "
|
|
858
|
+
"any human-requested change to a managed repo — immediate or scheduled."
|
|
823
859
|
),
|
|
824
860
|
"input_schema": {
|
|
825
861
|
"type": "object",
|
|
@@ -843,6 +879,16 @@ _TOOLS = [
|
|
|
843
879
|
"items": {"type": "string"},
|
|
844
880
|
"description": "Extra Slack user IDs to ping on completion (besides the submitter).",
|
|
845
881
|
},
|
|
882
|
+
"run_at": {
|
|
883
|
+
"type": "string",
|
|
884
|
+
"description": (
|
|
885
|
+
"Optional. Schedule the task for a future time. "
|
|
886
|
+
"Accepts natural language (e.g. '3 AM tomorrow Norwegian time', "
|
|
887
|
+
"'kl 03:00 norsk tid', 'at 15:30 CET', 'in 2 hours') "
|
|
888
|
+
"or an ISO 8601 datetime string. "
|
|
889
|
+
"If omitted the task runs immediately on the next poll cycle."
|
|
890
|
+
),
|
|
891
|
+
},
|
|
846
892
|
},
|
|
847
893
|
"required": ["repo_name", "description"],
|
|
848
894
|
},
|
|
@@ -1628,6 +1674,79 @@ _TOOLS = [
|
|
|
1628
1674
|
]
|
|
1629
1675
|
|
|
1630
1676
|
|
|
1677
|
+
# ── Scheduling helpers ────────────────────────────────────────────────────────
|
|
1678
|
+
|
|
1679
|
+
def _parse_run_at(raw: str) -> tuple:
|
|
1680
|
+
"""Parse a natural-language or ISO 8601 time expression into a UTC-aware datetime.
|
|
1681
|
+
|
|
1682
|
+
Returns (datetime, None) on success, (None, error_str) on failure.
|
|
1683
|
+
|
|
1684
|
+
Understands:
|
|
1685
|
+
- ISO 8601 strings with or without timezone
|
|
1686
|
+
- "at HH:MM [TZ]" / "kl HH:MM [norsk tid|CET|CEST|Oslo]"
|
|
1687
|
+
- "in N hours/minutes"
|
|
1688
|
+
- "tomorrow" modifier before or after HH:MM
|
|
1689
|
+
- Norwegian timezone keywords → Europe/Oslo
|
|
1690
|
+
"""
|
|
1691
|
+
import re as _re
|
|
1692
|
+
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
|
1693
|
+
|
|
1694
|
+
_UTC = _tz.utc
|
|
1695
|
+
|
|
1696
|
+
# Build Oslo timezone — prefer zoneinfo (accurate DST), fall back to fixed offset
|
|
1697
|
+
try:
|
|
1698
|
+
import zoneinfo as _zi
|
|
1699
|
+
_OSLO = _zi.ZoneInfo("Europe/Oslo")
|
|
1700
|
+
except Exception:
|
|
1701
|
+
# tzdata not installed or unavailable; use fixed UTC+1 (CET, conservative)
|
|
1702
|
+
_OSLO = _tz(_td(hours=1)) # type: ignore[assignment]
|
|
1703
|
+
|
|
1704
|
+
raw_l = raw.lower().strip()
|
|
1705
|
+
|
|
1706
|
+
# ── ISO 8601 ───────────────────────────────────────────────────────────────
|
|
1707
|
+
try:
|
|
1708
|
+
parsed = _dt.fromisoformat(raw)
|
|
1709
|
+
if parsed.tzinfo is None:
|
|
1710
|
+
parsed = parsed.replace(tzinfo=_OSLO)
|
|
1711
|
+
return parsed.astimezone(_UTC), None
|
|
1712
|
+
except ValueError:
|
|
1713
|
+
pass
|
|
1714
|
+
|
|
1715
|
+
# ── "in N hours/minutes" ──────────────────────────────────────────────────
|
|
1716
|
+
m = _re.search(r'in\s+(\d+)\s*(hour|hr|minute|min)', raw_l)
|
|
1717
|
+
if m:
|
|
1718
|
+
n = int(m.group(1))
|
|
1719
|
+
unit = m.group(2)
|
|
1720
|
+
delta = _td(hours=n) if unit.startswith("h") else _td(minutes=n)
|
|
1721
|
+
return _dt.now(_UTC) + delta, None
|
|
1722
|
+
|
|
1723
|
+
# ── "at HH:MM" / "kl HH:MM" with optional TZ and "tomorrow" ──────────────
|
|
1724
|
+
m = _re.search(r'(?:kl\.?\s*|at\s+)?(\d{1,2})[:\.](\d{2})', raw_l)
|
|
1725
|
+
if m:
|
|
1726
|
+
hour, minute = int(m.group(1)), int(m.group(2))
|
|
1727
|
+
|
|
1728
|
+
tz_hint = raw_l
|
|
1729
|
+
if any(kw in tz_hint for kw in ("norsk", "oslo", "norway", "norwegian", "cet", "cest")):
|
|
1730
|
+
tz = _OSLO
|
|
1731
|
+
elif "utc" in tz_hint:
|
|
1732
|
+
tz = _UTC
|
|
1733
|
+
else:
|
|
1734
|
+
tz = _OSLO # default for this project
|
|
1735
|
+
|
|
1736
|
+
today = _dt.now(tz).date()
|
|
1737
|
+
tomorrow = today + _td(days=1)
|
|
1738
|
+
base_date = tomorrow if "tomorrow" in raw_l or "i morgen" in raw_l else today
|
|
1739
|
+
|
|
1740
|
+
candidate = _dt(base_date.year, base_date.month, base_date.day, hour, minute, tzinfo=tz)
|
|
1741
|
+
# If the time is already past today, roll to tomorrow automatically
|
|
1742
|
+
if candidate <= _dt.now(tz) and "tomorrow" not in raw_l and "i morgen" not in raw_l:
|
|
1743
|
+
candidate += _td(days=1)
|
|
1744
|
+
|
|
1745
|
+
return candidate.astimezone(_UTC), None
|
|
1746
|
+
|
|
1747
|
+
return None, f"Unrecognised time expression: '{raw}'"
|
|
1748
|
+
|
|
1749
|
+
|
|
1631
1750
|
# ── Workspace helpers ─────────────────────────────────────────────────────────
|
|
1632
1751
|
|
|
1633
1752
|
def _workspace_dir() -> Path:
|
|
@@ -1745,6 +1864,61 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1745
1864
|
}
|
|
1746
1865
|
for e in errors[:8]
|
|
1747
1866
|
]
|
|
1867
|
+
|
|
1868
|
+
# ── Live queue scan ────────────────────────────────────────────────────
|
|
1869
|
+
# Show what is physically in the queue dirs, not just what is in the DB.
|
|
1870
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
1871
|
+
_now = _dt.now(_tz.utc)
|
|
1872
|
+
|
|
1873
|
+
def _queue_entries(queue_dir: Path) -> list[dict]:
|
|
1874
|
+
"""Return pending task/issue files with age and first-line preview."""
|
|
1875
|
+
entries = []
|
|
1876
|
+
if not queue_dir.exists():
|
|
1877
|
+
return entries
|
|
1878
|
+
for f in sorted(queue_dir.iterdir()):
|
|
1879
|
+
if not f.is_file() or f.name.startswith("."):
|
|
1880
|
+
continue
|
|
1881
|
+
try:
|
|
1882
|
+
lines = f.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1883
|
+
except OSError:
|
|
1884
|
+
continue
|
|
1885
|
+
# Parse RUN_AT if present (scheduled tasks held for the future)
|
|
1886
|
+
run_at_str = next(
|
|
1887
|
+
(l.split(":", 1)[1].strip() for l in lines
|
|
1888
|
+
if l.strip().upper().startswith("RUN_AT:")), None
|
|
1889
|
+
)
|
|
1890
|
+
run_at_dt = None
|
|
1891
|
+
if run_at_str:
|
|
1892
|
+
try:
|
|
1893
|
+
run_at_dt = _dt.fromisoformat(run_at_str).astimezone(_tz.utc)
|
|
1894
|
+
except ValueError:
|
|
1895
|
+
pass
|
|
1896
|
+
age_s = int((_now - _dt.fromtimestamp(f.stat().st_mtime, _tz.utc)).total_seconds())
|
|
1897
|
+
preview = next((l.strip() for l in lines if l.strip() and not l.strip().upper().startswith(
|
|
1898
|
+
("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "RUN_AT:", "NOTIFY:",
|
|
1899
|
+
"SOURCE:", "TARGET_REPO:", "FINGERPRINT:", "SUPPORT_URL:")
|
|
1900
|
+
)), f.name)
|
|
1901
|
+
entry = {
|
|
1902
|
+
"file": f.name,
|
|
1903
|
+
"age_seconds": age_s,
|
|
1904
|
+
"preview": preview[:120],
|
|
1905
|
+
}
|
|
1906
|
+
if run_at_dt:
|
|
1907
|
+
remaining = int((run_at_dt - _now).total_seconds())
|
|
1908
|
+
if remaining > 0:
|
|
1909
|
+
entry["scheduled_in_seconds"] = remaining
|
|
1910
|
+
entry["scheduled_at"] = run_at_dt.isoformat()
|
|
1911
|
+
entries.append(entry)
|
|
1912
|
+
return entries
|
|
1913
|
+
|
|
1914
|
+
_project_dirs = _find_project_dirs()
|
|
1915
|
+
_pd = _project_dirs[0] if _project_dirs else Path(".")
|
|
1916
|
+
queued_issues = _queue_entries(_pd / "issues")
|
|
1917
|
+
queued_repo_tasks = _queue_entries(_pd / "repo-tasks")
|
|
1918
|
+
# Scheduled tasks are repo-tasks with a future run_at
|
|
1919
|
+
scheduled_tasks = [t for t in queued_repo_tasks if "scheduled_in_seconds" in t]
|
|
1920
|
+
pending_repo_tasks = [t for t in queued_repo_tasks if "scheduled_in_seconds" not in t]
|
|
1921
|
+
|
|
1748
1922
|
return json.dumps({
|
|
1749
1923
|
"window_hours": hours,
|
|
1750
1924
|
"errors_detected": len(errors),
|
|
@@ -1762,6 +1936,9 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1762
1936
|
for p in prs
|
|
1763
1937
|
],
|
|
1764
1938
|
"sentinel_paused": Path("SENTINEL_PAUSE").exists(),
|
|
1939
|
+
"queued_issues": queued_issues,
|
|
1940
|
+
"queued_repo_tasks": pending_repo_tasks,
|
|
1941
|
+
"scheduled_tasks": scheduled_tasks,
|
|
1765
1942
|
})
|
|
1766
1943
|
|
|
1767
1944
|
if name == "check_auth_status":
|
|
@@ -2181,6 +2358,15 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2181
2358
|
if isinstance(notify_ids, list):
|
|
2182
2359
|
notify_ids = [u for u in notify_ids if u and u != user_id]
|
|
2183
2360
|
|
|
2361
|
+
# Parse optional scheduled time
|
|
2362
|
+
run_at_raw = (inputs.get("run_at") or "").strip()
|
|
2363
|
+
run_at_dt = None
|
|
2364
|
+
run_at_parse_error = None
|
|
2365
|
+
if run_at_raw:
|
|
2366
|
+
run_at_dt, run_at_parse_error = _parse_run_at(run_at_raw)
|
|
2367
|
+
if run_at_parse_error:
|
|
2368
|
+
return json.dumps({"error": f"Could not parse run_at: {run_at_parse_error}"})
|
|
2369
|
+
|
|
2184
2370
|
_project_dirs = _find_project_dirs()
|
|
2185
2371
|
if not _project_dirs:
|
|
2186
2372
|
return json.dumps({"error": "No project directory found."})
|
|
@@ -2193,8 +2379,29 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2193
2379
|
description=description,
|
|
2194
2380
|
submitter_user_id=user_id,
|
|
2195
2381
|
notify_user_ids=notify_ids,
|
|
2382
|
+
run_at=run_at_dt,
|
|
2383
|
+
)
|
|
2384
|
+
logger.info(
|
|
2385
|
+
"Boss repo_task: dropped %s for user %s (repo=%s, run_at=%s)",
|
|
2386
|
+
task_file.name, user_id, repo_name,
|
|
2387
|
+
run_at_dt.isoformat() if run_at_dt else "immediate",
|
|
2196
2388
|
)
|
|
2197
|
-
|
|
2389
|
+
if run_at_dt:
|
|
2390
|
+
import zoneinfo as _zi
|
|
2391
|
+
oslo = _zi.ZoneInfo("Europe/Oslo")
|
|
2392
|
+
local_str = run_at_dt.astimezone(oslo).strftime("%Y-%m-%d %H:%M %Z")
|
|
2393
|
+
return json.dumps({
|
|
2394
|
+
"status": "scheduled",
|
|
2395
|
+
"repo": repo_name,
|
|
2396
|
+
"task_type": task_type,
|
|
2397
|
+
"file": task_file.name,
|
|
2398
|
+
"run_at_utc": run_at_dt.isoformat(),
|
|
2399
|
+
"run_at_local": local_str,
|
|
2400
|
+
"note": (
|
|
2401
|
+
f"Task scheduled for `{repo_name}` at {local_str} — "
|
|
2402
|
+
"Sentinel will pick it up automatically when the time arrives."
|
|
2403
|
+
),
|
|
2404
|
+
})
|
|
2198
2405
|
return json.dumps({
|
|
2199
2406
|
"status": "queued",
|
|
2200
2407
|
"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
|