runbook-exec 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """runbook-exec: AI-driven runbook automation with safety gates and audit trails."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,38 @@
1
+ """Shared JSON parsing utilities for runbook-exec.
2
+
3
+ Centralises the markdown-fence stripping logic so every LLM response parser
4
+ goes through the same pre-processing step. This prevents the recurring bug
5
+ where the LLM wraps its JSON response in ```json ... ``` fences.
6
+ """
7
+
8
+ import re
9
+
10
+ # Matches opening fence: optional whitespace, ``` or ~~~, optional language tag
11
+ _FENCE_OPEN = re.compile(r"^\s*(?:```|~~~)\w*\s*\n?", re.MULTILINE)
12
+ # Matches closing fence: optional whitespace, ``` or ~~~
13
+ _FENCE_CLOSE = re.compile(r"\n?\s*(?:```|~~~)\s*$", re.MULTILINE)
14
+
15
+
16
+ def strip_markdown_fences(text: str) -> str:
17
+ """Strip markdown code fences from an LLM response.
18
+
19
+ Handles all common variants:
20
+ - ```json\\n{...}\\n```
21
+ - ```\\n{...}\\n```
22
+ - ~~~json\\n{...}\\n~~~
23
+ - Leading/trailing whitespace around fences
24
+
25
+ Args:
26
+ text: Raw LLM response text, possibly wrapped in code fences.
27
+
28
+ Returns:
29
+ The text with any enclosing code fence stripped, or the original
30
+ text unchanged if no fence is detected.
31
+ """
32
+ stripped = text.strip()
33
+ # Only strip if the text actually starts with a fence marker
34
+ if not (stripped.startswith("```") or stripped.startswith("~~~")):
35
+ return stripped
36
+ result = _FENCE_OPEN.sub("", stripped, count=1)
37
+ result = _FENCE_CLOSE.sub("", result, count=1)
38
+ return result.strip()
@@ -0,0 +1,527 @@
1
+ """Slack-based approval workflow for runbook-exec.
2
+
3
+ Manages approval requests and failure direction prompts via Slack Block Kit
4
+ messages and Socket Mode for real-time interaction without a public webhook.
5
+
6
+ When Slack is not configured (config.slack_enabled is False), both functions
7
+ fall back to interactive terminal prompts using rich.prompt.
8
+ """
9
+
10
+ import logging
11
+ import threading
12
+ from enum import Enum
13
+
14
+ import slack_sdk
15
+ import slack_sdk.socket_mode
16
+ from pydantic import BaseModel
17
+ from rich.prompt import Confirm, Prompt
18
+ from slack_sdk.socket_mode.request import SocketModeRequest
19
+ from slack_sdk.socket_mode.response import SocketModeResponse
20
+
21
+ from runbook_exec.exceptions import ApprovalError
22
+ from runbook_exec.models import Config, Step
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ApprovalResult(BaseModel):
28
+ """Result of an approval request."""
29
+
30
+ approved: bool
31
+ approver_slack_id: str | None = None
32
+ timed_out: bool = False
33
+
34
+
35
+ class FailureDirection(str, Enum):
36
+ """Direction chosen by the operator after a step failure."""
37
+
38
+ CONTINUE = "continue"
39
+ RETRY = "retry"
40
+ ABORT = "abort"
41
+ SKIP = "skip"
42
+
43
+
44
+ def _build_approval_blocks(step: Step, ts: str) -> list[dict]:
45
+ """Build Block Kit blocks for an approval request message."""
46
+ return [
47
+ {
48
+ "type": "header",
49
+ "text": {"type": "plain_text", "text": "⚠️ Approval Required"},
50
+ },
51
+ {
52
+ "type": "section",
53
+ "fields": [
54
+ {"type": "mrkdwn", "text": f"*Step:*\n{step.text}"},
55
+ {"type": "mrkdwn", "text": f"*Risk Level:*\n{step.risk_level}"},
56
+ ],
57
+ },
58
+ {
59
+ "type": "section",
60
+ "text": {"type": "mrkdwn", "text": f"*Command:*\n```{step.command}```"},
61
+ },
62
+ {
63
+ "type": "actions",
64
+ "elements": [
65
+ {
66
+ "type": "button",
67
+ "text": {"type": "plain_text", "text": "✅ Approve"},
68
+ "style": "primary",
69
+ "action_id": f"approve_{ts}",
70
+ },
71
+ {
72
+ "type": "button",
73
+ "text": {"type": "plain_text", "text": "❌ Deny"},
74
+ "style": "danger",
75
+ "action_id": f"deny_{ts}",
76
+ },
77
+ ],
78
+ },
79
+ ]
80
+
81
+
82
+ def _build_failure_direction_blocks(
83
+ step: Step,
84
+ failure_reason: str,
85
+ ts: str,
86
+ include_retry_warning: bool = False,
87
+ ) -> list[dict]:
88
+ """Build Block Kit blocks for a failure direction request message."""
89
+ blocks: list[dict] = [
90
+ {
91
+ "type": "header",
92
+ "text": {"type": "plain_text", "text": "⚠️ Step Failed — Choose Direction"},
93
+ },
94
+ {
95
+ "type": "section",
96
+ "fields": [
97
+ {"type": "mrkdwn", "text": f"*Step:*\n{step.text}"},
98
+ {"type": "mrkdwn", "text": f"*Risk Level:*\n{step.risk_level}"},
99
+ ],
100
+ },
101
+ {
102
+ "type": "section",
103
+ "text": {"type": "mrkdwn", "text": f"*Command:*\n```{step.command}```"},
104
+ },
105
+ {
106
+ "type": "section",
107
+ "text": {"type": "mrkdwn", "text": f"*Failure Reason:*\n{failure_reason}"},
108
+ },
109
+ ]
110
+
111
+ if include_retry_warning:
112
+ blocks.append(
113
+ {
114
+ "type": "section",
115
+ "text": {
116
+ "type": "mrkdwn",
117
+ "text": (
118
+ f"⚠️ WARNING: This step is {step.risk_level}. "
119
+ "Retry may have unintended side effects."
120
+ ),
121
+ },
122
+ }
123
+ )
124
+
125
+ blocks.append(
126
+ {
127
+ "type": "actions",
128
+ "elements": [
129
+ {
130
+ "type": "button",
131
+ "text": {"type": "plain_text", "text": "Continue"},
132
+ "action_id": f"continue_{ts}",
133
+ },
134
+ {
135
+ "type": "button",
136
+ "text": {"type": "plain_text", "text": "Retry"},
137
+ "action_id": f"retry_{ts}",
138
+ },
139
+ {
140
+ "type": "button",
141
+ "text": {"type": "plain_text", "text": "Skip"},
142
+ "action_id": f"skip_{ts}",
143
+ },
144
+ {
145
+ "type": "button",
146
+ "text": {"type": "plain_text", "text": "Abort"},
147
+ "style": "danger",
148
+ "action_id": f"abort_{ts}",
149
+ },
150
+ ],
151
+ }
152
+ )
153
+
154
+ return blocks
155
+
156
+
157
+ def _update_message_resolved(
158
+ web_client: slack_sdk.WebClient,
159
+ channel: str,
160
+ ts: str,
161
+ decision_text: str,
162
+ ) -> None:
163
+ """Update the Slack message to show the decision that was made."""
164
+ try:
165
+ web_client.chat_update(
166
+ channel=channel,
167
+ ts=ts,
168
+ text=decision_text,
169
+ blocks=[
170
+ {
171
+ "type": "section",
172
+ "text": {"type": "mrkdwn", "text": decision_text},
173
+ }
174
+ ],
175
+ )
176
+ except Exception as exc:
177
+ logger.warning(f"Failed to update Slack message after resolution: {exc}")
178
+
179
+
180
+ def _terminal_request_approval(step: Step, config: Config) -> ApprovalResult:
181
+ """Prompt for approval in the terminal when Slack is not configured.
182
+
183
+ Args:
184
+ step: The step requiring approval.
185
+ config: Runtime configuration.
186
+
187
+ Returns:
188
+ ApprovalResult based on the operator's terminal input.
189
+ """
190
+ from runbook_exec import display # local import to avoid circular dependency
191
+
192
+ display.show_warning(
193
+ f"Step {step.index} requires approval [{step.risk_level.value if step.risk_level else 'unknown'}]"
194
+ )
195
+ if step.command:
196
+ display.console.print(f" Command: [bold]{step.command}[/bold]")
197
+
198
+ approved = Confirm.ask(" Approve this step?", default=False)
199
+ return ApprovalResult(approved=approved, approver_slack_id=None)
200
+
201
+
202
+ def _terminal_request_failure_direction(
203
+ step: Step,
204
+ failure_reason: str,
205
+ config: Config,
206
+ include_retry_warning: bool = False,
207
+ ) -> FailureDirection:
208
+ """Prompt for failure direction in the terminal when Slack is not configured.
209
+
210
+ Args:
211
+ step: The step that failed.
212
+ failure_reason: Human-readable description of the failure.
213
+ config: Runtime configuration.
214
+ include_retry_warning: When True, prints a warning about retry side effects.
215
+
216
+ Returns:
217
+ FailureDirection chosen by the operator.
218
+ """
219
+ from runbook_exec import display # local import to avoid circular dependency
220
+
221
+ display.show_warning(f"Step {step.index} failed: {failure_reason}")
222
+ if include_retry_warning and step.risk_level:
223
+ display.show_warning(
224
+ f"This step is {step.risk_level.value}. Retry may have unintended side effects."
225
+ )
226
+
227
+ choice = Prompt.ask(
228
+ " Choose direction",
229
+ choices=["continue", "retry", "skip", "abort"],
230
+ default="abort",
231
+ )
232
+ return FailureDirection(choice)
233
+
234
+
235
+ def request_approval(step: Step, config: Config) -> ApprovalResult:
236
+ """Request approval for a step, using Slack or terminal depending on config.
237
+
238
+ Routes to the Slack workflow when both SLACK_BOT_TOKEN and SLACK_APP_TOKEN
239
+ are set (config.slack_enabled is True). Falls back to an interactive terminal
240
+ prompt otherwise.
241
+
242
+ Args:
243
+ step: The step requiring approval.
244
+ config: Runtime configuration with Slack tokens and channel.
245
+
246
+ Returns:
247
+ ApprovalResult with approved status, approver ID, and timeout flag.
248
+
249
+ Raises:
250
+ ApprovalError: If the Slack API call fails (Slack path only).
251
+ """
252
+ if not config.slack_enabled:
253
+ return _terminal_request_approval(step, config)
254
+ return _slack_request_approval(step, config)
255
+
256
+
257
+ def request_failure_direction(
258
+ step: Step,
259
+ failure_reason: str,
260
+ config: Config,
261
+ include_retry_warning: bool = False,
262
+ ) -> FailureDirection:
263
+ """Request failure direction, using Slack or terminal depending on config.
264
+
265
+ Routes to the Slack workflow when both SLACK_BOT_TOKEN and SLACK_APP_TOKEN
266
+ are set (config.slack_enabled is True). Falls back to an interactive terminal
267
+ prompt otherwise.
268
+
269
+ Args:
270
+ step: The step that failed.
271
+ failure_reason: Human-readable description of the failure.
272
+ config: Runtime configuration with Slack tokens and channel.
273
+ include_retry_warning: When True, includes a warning about retry side effects.
274
+
275
+ Returns:
276
+ FailureDirection enum value matching the operator's choice, or ABORT on timeout.
277
+
278
+ Raises:
279
+ ApprovalError: If the Slack API call fails (Slack path only).
280
+ """
281
+ if not config.slack_enabled:
282
+ return _terminal_request_failure_direction(
283
+ step, failure_reason, config, include_retry_warning
284
+ )
285
+ return _slack_request_failure_direction(step, failure_reason, config, include_retry_warning)
286
+
287
+
288
+ def _slack_request_approval(step: Step, config: Config) -> ApprovalResult:
289
+ """Post an approval request to Slack and wait for a button click.
290
+
291
+ Posts a Block Kit message to config.slack_channel with Approve/Deny buttons,
292
+ opens a Socket Mode connection, and waits for a response up to
293
+ config.timeout_seconds.
294
+
295
+ Args:
296
+ step: The step requiring approval.
297
+ config: Runtime configuration with Slack tokens and channel.
298
+
299
+ Returns:
300
+ ApprovalResult with approved status, approver ID, and timeout flag.
301
+
302
+ Raises:
303
+ ApprovalError: If the Slack API call fails.
304
+ """
305
+ try:
306
+ web_client = slack_sdk.WebClient(token=config.slack_bot_token)
307
+ response = web_client.chat_postMessage(
308
+ channel=config.slack_channel,
309
+ text=f"⚠️ Approval Required for step: {step.text}",
310
+ blocks=_build_approval_blocks(step, "placeholder"),
311
+ )
312
+ except Exception as exc:
313
+ raise ApprovalError(f"Failed to post approval request to Slack: {exc}") from exc
314
+
315
+ ts: str = response["ts"]
316
+
317
+ # Rebuild blocks with the real ts now that we have it
318
+ try:
319
+ web_client.chat_update(
320
+ channel=config.slack_channel,
321
+ ts=ts,
322
+ text=f"⚠️ Approval Required for step: {step.text}",
323
+ blocks=_build_approval_blocks(step, ts),
324
+ )
325
+ except Exception as exc:
326
+ raise ApprovalError(f"Failed to update approval message with action IDs: {exc}") from exc
327
+
328
+ result_holder: dict = {}
329
+ done_event = threading.Event()
330
+
331
+ def handle_socket_request(
332
+ socket_client: slack_sdk.socket_mode.SocketModeClient, req: SocketModeRequest
333
+ ) -> None:
334
+ """Handle incoming Socket Mode requests."""
335
+ if req.payload.get("type") != "block_actions":
336
+ return
337
+
338
+ # Acknowledge the interaction immediately
339
+ socket_client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
340
+
341
+ # Only handle actions for our posted message
342
+ container = req.payload.get("container", {})
343
+ message_ts = container.get("message_ts", "")
344
+ if message_ts != ts:
345
+ return
346
+
347
+ actions = req.payload.get("actions", [])
348
+ if not actions:
349
+ return
350
+
351
+ action_id: str = actions[0].get("action_id", "")
352
+ user_id: str = req.payload.get("user", {}).get("id", "")
353
+
354
+ if action_id == f"approve_{ts}":
355
+ result_holder["result"] = ApprovalResult(
356
+ approved=True,
357
+ approver_slack_id=user_id,
358
+ )
359
+ _update_message_resolved(
360
+ web_client,
361
+ config.slack_channel,
362
+ ts,
363
+ f"✅ Approved by <@{user_id}>",
364
+ )
365
+ done_event.set()
366
+ elif action_id == f"deny_{ts}":
367
+ result_holder["result"] = ApprovalResult(
368
+ approved=False,
369
+ approver_slack_id=user_id,
370
+ )
371
+ _update_message_resolved(
372
+ web_client,
373
+ config.slack_channel,
374
+ ts,
375
+ f"❌ Denied by <@{user_id}>",
376
+ )
377
+ done_event.set()
378
+
379
+ socket_client = slack_sdk.socket_mode.SocketModeClient(
380
+ app_token=config.slack_app_token,
381
+ web_client=web_client,
382
+ )
383
+ socket_client.socket_mode_request_listeners.append(handle_socket_request)
384
+
385
+ try:
386
+ socket_client.connect()
387
+ timed_out = not done_event.wait(timeout=config.timeout_seconds)
388
+ except KeyboardInterrupt:
389
+ _update_message_resolved(
390
+ web_client,
391
+ config.slack_channel,
392
+ ts,
393
+ "⚠️ Execution interrupted — no action taken",
394
+ )
395
+ socket_client.close()
396
+ raise
397
+ finally:
398
+ socket_client.close()
399
+
400
+ if timed_out:
401
+ return ApprovalResult(approved=False, timed_out=True)
402
+
403
+ return result_holder["result"]
404
+
405
+
406
+ def _slack_request_failure_direction(
407
+ step: Step,
408
+ failure_reason: str,
409
+ config: Config,
410
+ include_retry_warning: bool = False,
411
+ ) -> FailureDirection:
412
+ """Post a failure direction prompt to Slack and wait for a choice.
413
+
414
+ Posts a Block Kit message with Continue/Retry/Skip/Abort buttons,
415
+ opens a Socket Mode connection, and waits for a response up to
416
+ config.timeout_seconds. On timeout, returns FailureDirection.ABORT
417
+ as the safe default.
418
+
419
+ Args:
420
+ step: The step that failed.
421
+ failure_reason: Human-readable description of the failure.
422
+ config: Runtime configuration with Slack tokens and channel.
423
+ include_retry_warning: When True, includes a warning about retry
424
+ side effects for modifying/destructive steps.
425
+
426
+ Returns:
427
+ FailureDirection enum value matching the button clicked, or ABORT on timeout.
428
+
429
+ Raises:
430
+ ApprovalError: If the Slack API call fails.
431
+ """
432
+ try:
433
+ web_client = slack_sdk.WebClient(token=config.slack_bot_token)
434
+ response = web_client.chat_postMessage(
435
+ channel=config.slack_channel,
436
+ text=f"⚠️ Step failed: {step.text} — {failure_reason}",
437
+ blocks=_build_failure_direction_blocks(
438
+ step, failure_reason, "placeholder", include_retry_warning
439
+ ),
440
+ )
441
+ except Exception as exc:
442
+ raise ApprovalError(f"Failed to post failure direction request to Slack: {exc}") from exc
443
+
444
+ ts: str = response["ts"]
445
+
446
+ # Rebuild blocks with the real ts now that we have it
447
+ try:
448
+ web_client.chat_update(
449
+ channel=config.slack_channel,
450
+ ts=ts,
451
+ text=f"⚠️ Step failed: {step.text} — {failure_reason}",
452
+ blocks=_build_failure_direction_blocks(step, failure_reason, ts, include_retry_warning),
453
+ )
454
+ except Exception as exc:
455
+ raise ApprovalError(
456
+ f"Failed to update failure direction message with action IDs: {exc}"
457
+ ) from exc
458
+
459
+ result_holder: dict = {}
460
+ done_event = threading.Event()
461
+
462
+ _action_to_direction = {
463
+ f"continue_{ts}": FailureDirection.CONTINUE,
464
+ f"retry_{ts}": FailureDirection.RETRY,
465
+ f"skip_{ts}": FailureDirection.SKIP,
466
+ f"abort_{ts}": FailureDirection.ABORT,
467
+ }
468
+
469
+ def handle_socket_request(
470
+ socket_client: slack_sdk.socket_mode.SocketModeClient, req: SocketModeRequest
471
+ ) -> None:
472
+ """Handle incoming Socket Mode requests."""
473
+ if req.payload.get("type") != "block_actions":
474
+ return
475
+
476
+ # Acknowledge the interaction immediately
477
+ socket_client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
478
+
479
+ # Only handle actions for our posted message
480
+ container = req.payload.get("container", {})
481
+ message_ts = container.get("message_ts", "")
482
+ if message_ts != ts:
483
+ return
484
+
485
+ actions = req.payload.get("actions", [])
486
+ if not actions:
487
+ return
488
+
489
+ action_id: str = actions[0].get("action_id", "")
490
+ user_id: str = req.payload.get("user", {}).get("id", "")
491
+
492
+ direction = _action_to_direction.get(action_id)
493
+ if direction is not None:
494
+ result_holder["result"] = direction
495
+ _update_message_resolved(
496
+ web_client,
497
+ config.slack_channel,
498
+ ts,
499
+ f"Direction chosen by <@{user_id}>: *{direction.value}*",
500
+ )
501
+ done_event.set()
502
+
503
+ socket_client = slack_sdk.socket_mode.SocketModeClient(
504
+ app_token=config.slack_app_token,
505
+ web_client=web_client,
506
+ )
507
+ socket_client.socket_mode_request_listeners.append(handle_socket_request)
508
+
509
+ try:
510
+ socket_client.connect()
511
+ timed_out = not done_event.wait(timeout=config.timeout_seconds)
512
+ except KeyboardInterrupt:
513
+ _update_message_resolved(
514
+ web_client,
515
+ config.slack_channel,
516
+ ts,
517
+ "⚠️ Execution interrupted — no action taken",
518
+ )
519
+ socket_client.close()
520
+ raise
521
+ finally:
522
+ socket_client.close()
523
+
524
+ if timed_out:
525
+ return FailureDirection.ABORT
526
+
527
+ return result_holder["result"]