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.
- runbook_exec/__init__.py +3 -0
- runbook_exec/_json_utils.py +38 -0
- runbook_exec/approval.py +527 -0
- runbook_exec/audit.py +305 -0
- runbook_exec/classifier.py +105 -0
- runbook_exec/cli.py +130 -0
- runbook_exec/config.py +72 -0
- runbook_exec/display.py +248 -0
- runbook_exec/exceptions.py +57 -0
- runbook_exec/executor.py +455 -0
- runbook_exec/interrupt.py +27 -0
- runbook_exec/llm.py +55 -0
- runbook_exec/models.py +136 -0
- runbook_exec/parser.py +235 -0
- runbook_exec/shell.py +119 -0
- runbook_exec-0.1.0.dist-info/METADATA +282 -0
- runbook_exec-0.1.0.dist-info/RECORD +21 -0
- runbook_exec-0.1.0.dist-info/WHEEL +5 -0
- runbook_exec-0.1.0.dist-info/entry_points.txt +2 -0
- runbook_exec-0.1.0.dist-info/licenses/LICENSE +21 -0
- runbook_exec-0.1.0.dist-info/top_level.txt +1 -0
runbook_exec/__init__.py
ADDED
|
@@ -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()
|
runbook_exec/approval.py
ADDED
|
@@ -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"]
|