devs-webhook 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/PKG-INFO +1 -1
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/config.py +13 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/claude_dispatcher.py +55 -17
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/container_pool.py +49 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/test_dispatcher.py +53 -14
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/main_cli.py +49 -3
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/sqs_source.py +56 -4
- devs_webhook-0.1.4/devs_webhook/utils/container_logs.py +256 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/PKG-INFO +1 -1
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/SOURCES.txt +2 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/pyproject.toml +2 -2
- devs_webhook-0.1.4/tests/test_container_logs.py +247 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_sqs_burst.py +137 -2
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/LICENSE +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/README.md +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/__init__.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/app.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/cli/__init__.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/cli/worker.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/__init__.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/base_dispatcher.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/deduplication.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/repository_manager.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/task_processor.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/webhook_config.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/core/webhook_handler.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/__init__.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/app_auth.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/client.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/models.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/github/parser.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/__init__.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/base.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/sources/webhook_source.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/__init__.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/async_utils.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/github.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/logging.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook/utils/serialization.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/dependency_links.txt +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/entry_points.txt +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/requires.txt +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/devs_webhook.egg-info/top_level.txt +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/setup.cfg +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_allowlist.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_authentication.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_authorized_users.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_single_queue.py +0 -0
- {devs_webhook-0.1.2 → devs_webhook-0.1.4}/tests/test_webhook_parser.py +0 -0
|
@@ -89,6 +89,16 @@ class WebhookConfig(BaseSettings, BaseConfig):
|
|
|
89
89
|
log_level: str = Field(default="INFO", description="Logging level")
|
|
90
90
|
log_format: str = Field(default="json", description="Logging format (json|console)")
|
|
91
91
|
|
|
92
|
+
# Container output logging (CloudWatch-friendly)
|
|
93
|
+
container_logs_dir: Path = Field(
|
|
94
|
+
default_factory=lambda: Path("/var/log/devs-webhook/containers"),
|
|
95
|
+
description="Directory for container output logs (CloudWatch agent compatible)"
|
|
96
|
+
)
|
|
97
|
+
container_logs_enabled: bool = Field(
|
|
98
|
+
default=False,
|
|
99
|
+
description="Enable writing container output to log files"
|
|
100
|
+
)
|
|
101
|
+
|
|
92
102
|
# Task source configuration
|
|
93
103
|
task_source: str = Field(
|
|
94
104
|
default="webhook",
|
|
@@ -166,6 +176,9 @@ class WebhookConfig(BaseSettings, BaseConfig):
|
|
|
166
176
|
self.repo_cache_dir.mkdir(parents=True, exist_ok=True)
|
|
167
177
|
# Claude config directory for container mounts
|
|
168
178
|
self.claude_config_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
# Container logs directory (if enabled)
|
|
180
|
+
if self.container_logs_enabled:
|
|
181
|
+
self.container_logs_dir.mkdir(parents=True, exist_ok=True)
|
|
169
182
|
|
|
170
183
|
def validate_required_settings(self) -> None:
|
|
171
184
|
"""Validate that required settings are present."""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Claude Code CLI integration for executing tasks in containers."""
|
|
2
2
|
|
|
3
|
+
import uuid
|
|
3
4
|
from typing import Optional
|
|
4
5
|
import structlog
|
|
5
6
|
from pathlib import Path
|
|
@@ -10,6 +11,7 @@ from devs_common.core.workspace import WorkspaceManager
|
|
|
10
11
|
from ..github.models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent
|
|
11
12
|
from devs_common.devs_config import DevsOptions
|
|
12
13
|
from .base_dispatcher import BaseDispatcher, TaskResult
|
|
14
|
+
from ..utils.container_logs import create_container_log_writer
|
|
13
15
|
|
|
14
16
|
logger = structlog.get_logger()
|
|
15
17
|
|
|
@@ -29,36 +31,44 @@ class ClaudeDispatcher(BaseDispatcher):
|
|
|
29
31
|
repo_path: Path,
|
|
30
32
|
event: WebhookEvent,
|
|
31
33
|
devs_options: Optional[DevsOptions] = None,
|
|
32
|
-
task_description: Optional[str] = None
|
|
34
|
+
task_description: Optional[str] = None,
|
|
35
|
+
task_id: Optional[str] = None
|
|
33
36
|
) -> TaskResult:
|
|
34
37
|
"""Execute a task using Claude Code CLI in a container.
|
|
35
|
-
|
|
38
|
+
|
|
36
39
|
Args:
|
|
37
40
|
dev_name: Name of dev container (e.g., eamonn)
|
|
38
41
|
repo_path: Path to repository on host (already calculated by container_pool)
|
|
39
42
|
task_description: Task description for Claude
|
|
40
43
|
event: Original webhook event
|
|
41
44
|
devs_options: Options from DEVS.yml file
|
|
42
|
-
|
|
45
|
+
task_id: Optional task identifier for logging
|
|
46
|
+
|
|
43
47
|
Returns:
|
|
44
48
|
Task execution result
|
|
45
49
|
"""
|
|
50
|
+
# Generate task_id if not provided
|
|
51
|
+
if not task_id:
|
|
52
|
+
task_id = str(uuid.uuid4())[:8]
|
|
53
|
+
|
|
46
54
|
try:
|
|
47
55
|
logger.info("Starting Claude Code CLI task",
|
|
48
56
|
container=dev_name,
|
|
49
57
|
repo=event.repository.full_name,
|
|
50
|
-
repo_path=str(repo_path)
|
|
51
|
-
|
|
58
|
+
repo_path=str(repo_path),
|
|
59
|
+
task_id=task_id)
|
|
60
|
+
|
|
52
61
|
# Execute Claude directly - prompt building, workspace setup, container startup, Claude execution
|
|
53
62
|
# Use task_description if provided, otherwise extract from event
|
|
54
63
|
task_desc = task_description or "Process webhook event"
|
|
55
|
-
|
|
64
|
+
|
|
56
65
|
success, output, error = self._execute_claude_sync(
|
|
57
66
|
repo_path,
|
|
58
67
|
dev_name,
|
|
59
68
|
task_desc,
|
|
60
69
|
event,
|
|
61
|
-
devs_options
|
|
70
|
+
devs_options,
|
|
71
|
+
task_id=task_id
|
|
62
72
|
)
|
|
63
73
|
|
|
64
74
|
# Build result - ensure we have meaningful error messages
|
|
@@ -107,26 +117,37 @@ class ClaudeDispatcher(BaseDispatcher):
|
|
|
107
117
|
dev_name: str,
|
|
108
118
|
task_description: str,
|
|
109
119
|
event: WebhookEvent,
|
|
110
|
-
devs_options: Optional[DevsOptions] = None
|
|
120
|
+
devs_options: Optional[DevsOptions] = None,
|
|
121
|
+
task_id: Optional[str] = None
|
|
111
122
|
) -> tuple[bool, str, str]:
|
|
112
123
|
"""Execute complete Claude workflow synchronously.
|
|
113
|
-
|
|
124
|
+
|
|
114
125
|
This mirrors the CLI approach exactly:
|
|
115
126
|
1. Create project, workspace manager, and container manager
|
|
116
127
|
2. Create/reset workspace (force=True for webhook)
|
|
117
128
|
3. Build prompt
|
|
118
129
|
4. Execute Claude (which handles container startup)
|
|
119
|
-
|
|
130
|
+
|
|
120
131
|
Args:
|
|
121
132
|
repo_path: Path to repository
|
|
122
133
|
dev_name: Development environment name
|
|
123
134
|
task_description: Task description for Claude
|
|
124
135
|
event: Webhook event
|
|
125
136
|
devs_options: Options from DEVS.yml
|
|
126
|
-
|
|
137
|
+
task_id: Optional task identifier for logging
|
|
138
|
+
|
|
127
139
|
Returns:
|
|
128
140
|
Tuple of (success, stdout, stderr)
|
|
129
141
|
"""
|
|
142
|
+
# Create container log writer if enabled
|
|
143
|
+
container_log = create_container_log_writer(
|
|
144
|
+
config=self.config,
|
|
145
|
+
container_name=dev_name,
|
|
146
|
+
task_id=task_id or str(uuid.uuid4())[:8],
|
|
147
|
+
repo_name=event.repository.full_name,
|
|
148
|
+
task_type="claude"
|
|
149
|
+
)
|
|
150
|
+
|
|
130
151
|
try:
|
|
131
152
|
# 1. Create project, workspace manager, and container manager like CLI
|
|
132
153
|
project = Project(repo_path)
|
|
@@ -240,11 +261,15 @@ Always remember to PUSH your work to origin!
|
|
|
240
261
|
# 4. Execute Claude (like CLI pattern) with environment variables from DEVS.yml
|
|
241
262
|
logger.info("Executing Claude via ContainerManager (like CLI)",
|
|
242
263
|
container=dev_name)
|
|
243
|
-
|
|
264
|
+
|
|
244
265
|
extra_env = None
|
|
245
266
|
if devs_options:
|
|
246
267
|
extra_env = devs_options.get_env_vars(dev_name)
|
|
247
|
-
|
|
268
|
+
|
|
269
|
+
# Start container logging if enabled
|
|
270
|
+
if container_log:
|
|
271
|
+
container_log.start(prompt=prompt, workspace_dir=str(workspace_dir))
|
|
272
|
+
|
|
248
273
|
success, stdout, stderr = container_manager.exec_claude(
|
|
249
274
|
dev_name=dev_name,
|
|
250
275
|
workspace_dir=workspace_dir,
|
|
@@ -253,7 +278,15 @@ Always remember to PUSH your work to origin!
|
|
|
253
278
|
stream=False, # Don't stream in webhook mode
|
|
254
279
|
extra_env=extra_env
|
|
255
280
|
)
|
|
256
|
-
|
|
281
|
+
|
|
282
|
+
# Write container output to log file if enabled
|
|
283
|
+
if container_log:
|
|
284
|
+
container_log.write_output(stdout, stderr)
|
|
285
|
+
container_log.end(
|
|
286
|
+
success=success,
|
|
287
|
+
error=stderr if not success else None
|
|
288
|
+
)
|
|
289
|
+
|
|
257
290
|
# Log the actual output for debugging
|
|
258
291
|
if not success:
|
|
259
292
|
logger.error("Claude execution failed",
|
|
@@ -261,20 +294,25 @@ Always remember to PUSH your work to origin!
|
|
|
261
294
|
stdout=stdout[:1000] if stdout else "",
|
|
262
295
|
stderr=stderr[:1000] if stderr else "",
|
|
263
296
|
success=success)
|
|
264
|
-
|
|
297
|
+
|
|
265
298
|
# If failed and no stderr, check stdout for error messages
|
|
266
299
|
# (Claude sometimes outputs errors to stdout)
|
|
267
300
|
if not success and not stderr:
|
|
268
301
|
stderr = stdout
|
|
269
|
-
|
|
302
|
+
|
|
270
303
|
return success, stdout, stderr
|
|
271
|
-
|
|
304
|
+
|
|
272
305
|
except Exception as e:
|
|
273
306
|
error_msg = f"Claude execution failed: {str(e)}"
|
|
274
307
|
logger.error("Claude execution error",
|
|
275
308
|
container=dev_name,
|
|
276
309
|
error=error_msg,
|
|
277
310
|
exc_info=True)
|
|
311
|
+
|
|
312
|
+
# Log the error to container log if enabled
|
|
313
|
+
if container_log:
|
|
314
|
+
container_log.end(success=False, error=error_msg)
|
|
315
|
+
|
|
278
316
|
return False, "", error_msg
|
|
279
317
|
|
|
280
318
|
|
|
@@ -929,6 +929,55 @@ Please check the webhook handler logs for more details, or try mentioning me aga
|
|
|
929
929
|
"cached_repo_configs": list(self.repo_configs.keys()),
|
|
930
930
|
}
|
|
931
931
|
|
|
932
|
+
def get_total_queued_tasks(self) -> int:
|
|
933
|
+
"""Get the total number of tasks queued across all containers.
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
Total number of tasks waiting in all queues
|
|
937
|
+
"""
|
|
938
|
+
return sum(queue.qsize() for queue in self.container_queues.values())
|
|
939
|
+
|
|
940
|
+
async def wait_for_all_tasks_complete(self, timeout: Optional[float] = None) -> bool:
|
|
941
|
+
"""Wait for all queued tasks to be processed.
|
|
942
|
+
|
|
943
|
+
This waits for all container queues to be fully drained, meaning
|
|
944
|
+
all tasks have been picked up by workers AND task_done() has been
|
|
945
|
+
called for each (i.e., processing is complete, not just started).
|
|
946
|
+
|
|
947
|
+
Args:
|
|
948
|
+
timeout: Optional timeout in seconds. If None, waits indefinitely.
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
True if all tasks completed, False if timeout occurred.
|
|
952
|
+
"""
|
|
953
|
+
logger.info("Waiting for all container queues to drain",
|
|
954
|
+
queues={name: q.qsize() for name, q in self.container_queues.items()})
|
|
955
|
+
|
|
956
|
+
async def wait_all_queues():
|
|
957
|
+
# Wait for each queue to be fully processed
|
|
958
|
+
# asyncio.Queue.join() waits until all items have had task_done() called
|
|
959
|
+
wait_tasks = [
|
|
960
|
+
queue.join()
|
|
961
|
+
for queue in self.container_queues.values()
|
|
962
|
+
]
|
|
963
|
+
await asyncio.gather(*wait_tasks)
|
|
964
|
+
|
|
965
|
+
try:
|
|
966
|
+
if timeout is not None:
|
|
967
|
+
await asyncio.wait_for(wait_all_queues(), timeout=timeout)
|
|
968
|
+
else:
|
|
969
|
+
await wait_all_queues()
|
|
970
|
+
|
|
971
|
+
logger.info("All container queues drained successfully")
|
|
972
|
+
return True
|
|
973
|
+
|
|
974
|
+
except asyncio.TimeoutError:
|
|
975
|
+
remaining = {name: q.qsize() for name, q in self.container_queues.items()}
|
|
976
|
+
logger.warning("Timeout waiting for queues to drain",
|
|
977
|
+
remaining_tasks=remaining,
|
|
978
|
+
timeout_seconds=timeout)
|
|
979
|
+
return False
|
|
980
|
+
|
|
932
981
|
async def _idle_cleanup_worker(self) -> None:
|
|
933
982
|
"""Periodically clean up idle containers."""
|
|
934
983
|
while True:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Test runner dispatcher for executing CI tests in containers."""
|
|
2
2
|
|
|
3
|
+
import uuid
|
|
3
4
|
from typing import Optional
|
|
4
5
|
import structlog
|
|
5
6
|
from pathlib import Path
|
|
@@ -10,6 +11,7 @@ from devs_common.core.workspace import WorkspaceManager
|
|
|
10
11
|
from ..github.models import WebhookEvent, PushEvent, PullRequestEvent
|
|
11
12
|
from devs_common.devs_config import DevsOptions
|
|
12
13
|
from .base_dispatcher import BaseDispatcher, TaskResult
|
|
14
|
+
from ..utils.container_logs import create_container_log_writer
|
|
13
15
|
|
|
14
16
|
logger = structlog.get_logger()
|
|
15
17
|
|
|
@@ -29,27 +31,34 @@ class TestDispatcher(BaseDispatcher):
|
|
|
29
31
|
repo_path: Path,
|
|
30
32
|
event: WebhookEvent,
|
|
31
33
|
devs_options: Optional[DevsOptions] = None,
|
|
32
|
-
task_description: Optional[str] = None
|
|
34
|
+
task_description: Optional[str] = None,
|
|
35
|
+
task_id: Optional[str] = None
|
|
33
36
|
) -> TaskResult:
|
|
34
37
|
"""Execute tests using container and report results via GitHub Checks API.
|
|
35
|
-
|
|
38
|
+
|
|
36
39
|
Args:
|
|
37
40
|
dev_name: Name of dev container (e.g., eamonn)
|
|
38
41
|
repo_path: Path to repository on host (already calculated by container_pool)
|
|
39
42
|
event: Original webhook event
|
|
40
43
|
devs_options: Options from DEVS.yml file
|
|
41
44
|
task_description: Task description (ignored by test dispatcher)
|
|
42
|
-
|
|
45
|
+
task_id: Optional task identifier for logging
|
|
46
|
+
|
|
43
47
|
Returns:
|
|
44
48
|
Test execution result
|
|
45
49
|
"""
|
|
50
|
+
# Generate task_id if not provided
|
|
51
|
+
if not task_id:
|
|
52
|
+
task_id = str(uuid.uuid4())[:8]
|
|
53
|
+
|
|
46
54
|
check_run_id = None
|
|
47
|
-
|
|
55
|
+
|
|
48
56
|
try:
|
|
49
57
|
logger.info("Starting test execution",
|
|
50
58
|
container=dev_name,
|
|
51
59
|
repo=event.repository.full_name,
|
|
52
|
-
repo_path=str(repo_path)
|
|
60
|
+
repo_path=str(repo_path),
|
|
61
|
+
task_id=task_id)
|
|
53
62
|
|
|
54
63
|
# Determine the commit SHA to test
|
|
55
64
|
commit_sha = self._get_commit_sha(event)
|
|
@@ -106,7 +115,8 @@ class TestDispatcher(BaseDispatcher):
|
|
|
106
115
|
repo_path,
|
|
107
116
|
dev_name,
|
|
108
117
|
event,
|
|
109
|
-
devs_options
|
|
118
|
+
devs_options,
|
|
119
|
+
task_id=task_id
|
|
110
120
|
)
|
|
111
121
|
|
|
112
122
|
# Build result
|
|
@@ -186,19 +196,30 @@ class TestDispatcher(BaseDispatcher):
|
|
|
186
196
|
repo_path: Path,
|
|
187
197
|
dev_name: str,
|
|
188
198
|
event: WebhookEvent,
|
|
189
|
-
devs_options: Optional[DevsOptions] = None
|
|
199
|
+
devs_options: Optional[DevsOptions] = None,
|
|
200
|
+
task_id: Optional[str] = None
|
|
190
201
|
) -> tuple[bool, str, str, int]:
|
|
191
202
|
"""Execute tests synchronously in container.
|
|
192
|
-
|
|
203
|
+
|
|
193
204
|
Args:
|
|
194
205
|
repo_path: Path to repository
|
|
195
206
|
dev_name: Development environment name
|
|
196
207
|
event: Webhook event
|
|
197
208
|
devs_options: Options from DEVS.yml
|
|
198
|
-
|
|
209
|
+
task_id: Optional task identifier for logging
|
|
210
|
+
|
|
199
211
|
Returns:
|
|
200
212
|
Tuple of (success, stdout, stderr, exit_code)
|
|
201
213
|
"""
|
|
214
|
+
# Create container log writer if enabled
|
|
215
|
+
container_log = create_container_log_writer(
|
|
216
|
+
config=self.config,
|
|
217
|
+
container_name=dev_name,
|
|
218
|
+
task_id=task_id or str(uuid.uuid4())[:8],
|
|
219
|
+
repo_name=event.repository.full_name,
|
|
220
|
+
task_type="tests"
|
|
221
|
+
)
|
|
222
|
+
|
|
202
223
|
try:
|
|
203
224
|
# 1. Create project, workspace manager, and container manager
|
|
204
225
|
project = Project(repo_path)
|
|
@@ -256,11 +277,15 @@ class TestDispatcher(BaseDispatcher):
|
|
|
256
277
|
test_command = "./runtests.sh" # Default
|
|
257
278
|
if devs_options and devs_options.ci_test_command:
|
|
258
279
|
test_command = devs_options.ci_test_command
|
|
259
|
-
|
|
280
|
+
|
|
260
281
|
logger.info("Executing test command",
|
|
261
282
|
container=dev_name,
|
|
262
283
|
test_command=test_command)
|
|
263
|
-
|
|
284
|
+
|
|
285
|
+
# Start container logging if enabled
|
|
286
|
+
if container_log:
|
|
287
|
+
container_log.start(test_command=test_command, workspace_dir=str(workspace_dir))
|
|
288
|
+
|
|
264
289
|
# 6. Execute tests
|
|
265
290
|
success, stdout, stderr, exit_code = self._exec_command_in_container(
|
|
266
291
|
project=project,
|
|
@@ -269,22 +294,36 @@ class TestDispatcher(BaseDispatcher):
|
|
|
269
294
|
command=test_command,
|
|
270
295
|
debug=self.config.dev_mode
|
|
271
296
|
)
|
|
272
|
-
|
|
297
|
+
|
|
298
|
+
# Write container output to log file if enabled
|
|
299
|
+
if container_log:
|
|
300
|
+
container_log.write_output(stdout, stderr)
|
|
301
|
+
container_log.end(
|
|
302
|
+
success=success,
|
|
303
|
+
exit_code=exit_code,
|
|
304
|
+
error=stderr if not success else None
|
|
305
|
+
)
|
|
306
|
+
|
|
273
307
|
logger.info("Test command completed",
|
|
274
308
|
container=dev_name,
|
|
275
309
|
success=success,
|
|
276
310
|
exit_code=exit_code,
|
|
277
311
|
output_length=len(stdout) if stdout else 0,
|
|
278
312
|
error_length=len(stderr) if stderr else 0)
|
|
279
|
-
|
|
313
|
+
|
|
280
314
|
return success, stdout, stderr, exit_code
|
|
281
|
-
|
|
315
|
+
|
|
282
316
|
except Exception as e:
|
|
283
317
|
error_msg = f"Test execution failed: {str(e)}"
|
|
284
318
|
logger.error("Test execution error",
|
|
285
319
|
container=dev_name,
|
|
286
320
|
error=error_msg,
|
|
287
321
|
exc_info=True)
|
|
322
|
+
|
|
323
|
+
# Log the error to container log if enabled
|
|
324
|
+
if container_log:
|
|
325
|
+
container_log.end(success=False, exit_code=1, error=error_msg)
|
|
326
|
+
|
|
288
327
|
return False, "", error_msg, 1
|
|
289
328
|
|
|
290
329
|
def _get_commit_sha(self, event: WebhookEvent) -> Optional[str]:
|
|
@@ -30,7 +30,11 @@ cli.add_command(worker)
|
|
|
30
30
|
@click.option('--dev', is_flag=True, help='Development mode (auto-loads .env, enables reload, console logs)')
|
|
31
31
|
@click.option('--source', type=click.Choice(['webhook', 'sqs'], case_sensitive=False), help='Task source override')
|
|
32
32
|
@click.option('--burst', is_flag=True, help='Burst mode: process all available SQS messages then exit (SQS mode only)')
|
|
33
|
-
|
|
33
|
+
@click.option('--no-wait', is_flag=True, help='In burst mode, exit immediately after draining SQS queue without waiting for tasks to complete')
|
|
34
|
+
@click.option('--timeout', type=int, default=None, help='Timeout in seconds for waiting on task completion in burst mode (default: wait indefinitely)')
|
|
35
|
+
@click.option('--container-logs', is_flag=True, help='Enable container output logging to files (CloudWatch compatible)')
|
|
36
|
+
@click.option('--container-logs-dir', type=click.Path(path_type=Path), default=None, help='Directory for container log files (default: /var/log/devs-webhook/containers)')
|
|
37
|
+
def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source: str, burst: bool, no_wait: bool, timeout: int, container_logs: bool, container_logs_dir: Path):
|
|
34
38
|
"""Start the webhook handler server.
|
|
35
39
|
|
|
36
40
|
The server can run in two modes:
|
|
@@ -40,14 +44,21 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
|
|
|
40
44
|
SQS mode supports --burst flag to process all available messages then exit:
|
|
41
45
|
- Exit code 0: Processed one or more messages successfully
|
|
42
46
|
- Exit code 42: Queue was empty (no messages to process)
|
|
47
|
+
- Exit code 43: Timeout waiting for tasks to complete
|
|
43
48
|
- Other codes: Error occurred
|
|
44
49
|
|
|
50
|
+
By default, burst mode waits for all container tasks (Docker jobs) to complete
|
|
51
|
+
before exiting. Use --no-wait to exit immediately after draining the SQS queue,
|
|
52
|
+
or --timeout to set a maximum wait time.
|
|
53
|
+
|
|
45
54
|
Examples:
|
|
46
55
|
devs-webhook serve --dev # Development mode with .env loading
|
|
47
56
|
devs-webhook serve --env-file /path/.env # Load specific .env file
|
|
48
57
|
devs-webhook serve --host 127.0.0.1 # Override host from config
|
|
49
58
|
devs-webhook serve --source sqs # Use SQS polling mode
|
|
50
|
-
devs-webhook serve --source sqs --burst # Process all SQS messages
|
|
59
|
+
devs-webhook serve --source sqs --burst # Process all SQS messages, wait for completion
|
|
60
|
+
devs-webhook serve --source sqs --burst --no-wait # Drain SQS and exit immediately
|
|
61
|
+
devs-webhook serve --source sqs --burst --timeout 3600 # Wait up to 1 hour for tasks
|
|
51
62
|
"""
|
|
52
63
|
# Handle development mode
|
|
53
64
|
if dev:
|
|
@@ -87,6 +98,12 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
|
|
|
87
98
|
if source:
|
|
88
99
|
os.environ["TASK_SOURCE"] = source
|
|
89
100
|
|
|
101
|
+
# Configure container logs if specified via CLI
|
|
102
|
+
if container_logs:
|
|
103
|
+
os.environ["CONTAINER_LOGS_ENABLED"] = "true"
|
|
104
|
+
if container_logs_dir:
|
|
105
|
+
os.environ["CONTAINER_LOGS_DIR"] = str(container_logs_dir)
|
|
106
|
+
|
|
90
107
|
# Now setup logging after environment is configured
|
|
91
108
|
setup_logging()
|
|
92
109
|
|
|
@@ -97,6 +114,8 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
|
|
|
97
114
|
click.echo(f"Task source: {config.task_source}")
|
|
98
115
|
click.echo(f"Watching for @{config.github_mentioned_user} mentions")
|
|
99
116
|
click.echo(f"Container pool: {', '.join(config.get_container_pool_list())}")
|
|
117
|
+
if config.container_logs_enabled:
|
|
118
|
+
click.echo(f"Container logs: {config.container_logs_dir}")
|
|
100
119
|
|
|
101
120
|
# Validate burst mode is only used with SQS
|
|
102
121
|
if burst and config.task_source != "sqs":
|
|
@@ -128,13 +147,24 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
|
|
|
128
147
|
click.echo(f"DLQ configured: {config.aws_sqs_dlq_url}")
|
|
129
148
|
if burst:
|
|
130
149
|
click.echo("Burst mode: will process all messages then exit")
|
|
150
|
+
if no_wait:
|
|
151
|
+
click.echo(" --no-wait: will NOT wait for container tasks to complete")
|
|
152
|
+
else:
|
|
153
|
+
if timeout:
|
|
154
|
+
click.echo(f" Will wait up to {timeout}s for container tasks to complete")
|
|
155
|
+
else:
|
|
156
|
+
click.echo(" Will wait for all container tasks to complete before exit")
|
|
131
157
|
|
|
132
158
|
# Import and run SQS source
|
|
133
159
|
import asyncio
|
|
134
160
|
from .sources.sqs_source import SQSTaskSource
|
|
135
161
|
|
|
136
162
|
async def run_sqs():
|
|
137
|
-
sqs_source = SQSTaskSource(
|
|
163
|
+
sqs_source = SQSTaskSource(
|
|
164
|
+
burst_mode=burst,
|
|
165
|
+
wait_for_tasks=not no_wait,
|
|
166
|
+
task_timeout=float(timeout) if timeout else None,
|
|
167
|
+
)
|
|
138
168
|
try:
|
|
139
169
|
return await sqs_source.start()
|
|
140
170
|
except KeyboardInterrupt:
|
|
@@ -149,6 +179,22 @@ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source:
|
|
|
149
179
|
if result.messages_processed == 0:
|
|
150
180
|
click.echo("Queue was empty, no messages processed")
|
|
151
181
|
exit(42)
|
|
182
|
+
elif no_wait:
|
|
183
|
+
# Not waiting for tasks - just report messages processed
|
|
184
|
+
click.echo(f"Burst complete: queued {result.messages_processed} message(s)")
|
|
185
|
+
click.echo(" (not waiting for container tasks to complete)")
|
|
186
|
+
exit(0)
|
|
187
|
+
elif result.tasks_completed == result.messages_processed:
|
|
188
|
+
# All tasks completed successfully
|
|
189
|
+
click.echo(f"Burst complete: processed {result.messages_processed} message(s), "
|
|
190
|
+
f"all {result.tasks_completed} task(s) completed")
|
|
191
|
+
exit(0)
|
|
192
|
+
elif result.tasks_completed < result.messages_processed:
|
|
193
|
+
# Timeout - some tasks didn't complete
|
|
194
|
+
remaining = result.messages_processed - result.tasks_completed
|
|
195
|
+
click.echo(f"Burst timeout: processed {result.messages_processed} message(s), "
|
|
196
|
+
f"but {remaining} task(s) still running")
|
|
197
|
+
exit(43)
|
|
152
198
|
else:
|
|
153
199
|
click.echo(f"Burst complete: processed {result.messages_processed} message(s)")
|
|
154
200
|
exit(0)
|
|
@@ -23,6 +23,7 @@ logger = structlog.get_logger()
|
|
|
23
23
|
class BurstResult:
|
|
24
24
|
"""Result of a burst mode SQS run."""
|
|
25
25
|
messages_processed: int
|
|
26
|
+
tasks_completed: int = 0
|
|
26
27
|
errors: int = 0
|
|
27
28
|
|
|
28
29
|
|
|
@@ -44,7 +45,13 @@ class SQSTaskSource(TaskSource):
|
|
|
44
45
|
}
|
|
45
46
|
"""
|
|
46
47
|
|
|
47
|
-
def __init__(
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
task_processor: Optional[TaskProcessor] = None,
|
|
51
|
+
burst_mode: bool = False,
|
|
52
|
+
wait_for_tasks: bool = True,
|
|
53
|
+
task_timeout: Optional[float] = None,
|
|
54
|
+
):
|
|
48
55
|
"""Initialize SQS task source.
|
|
49
56
|
|
|
50
57
|
Args:
|
|
@@ -52,13 +59,21 @@ class SQSTaskSource(TaskSource):
|
|
|
52
59
|
a new one will be created.
|
|
53
60
|
burst_mode: If True, process all available messages and exit instead
|
|
54
61
|
of polling indefinitely.
|
|
62
|
+
wait_for_tasks: If True (default), burst mode will wait for all
|
|
63
|
+
queued tasks to complete before exiting. If False,
|
|
64
|
+
exits as soon as SQS queue is drained.
|
|
65
|
+
task_timeout: Optional timeout in seconds for waiting on task completion
|
|
66
|
+
in burst mode. If None, waits indefinitely.
|
|
55
67
|
"""
|
|
56
68
|
self.task_processor = task_processor or TaskProcessor()
|
|
57
69
|
self.config = get_config()
|
|
58
70
|
self._running = False
|
|
59
71
|
self._poll_task: Optional[asyncio.Task] = None
|
|
60
72
|
self._burst_mode = burst_mode
|
|
73
|
+
self._wait_for_tasks = wait_for_tasks
|
|
74
|
+
self._task_timeout = task_timeout
|
|
61
75
|
self._messages_processed = 0
|
|
76
|
+
self._tasks_completed = 0
|
|
62
77
|
self._errors = 0
|
|
63
78
|
|
|
64
79
|
# Import boto3 lazily to avoid requiring it for webhook-only deployments
|
|
@@ -80,6 +95,8 @@ class SQSTaskSource(TaskSource):
|
|
|
80
95
|
queue_url=self.config.aws_sqs_queue_url,
|
|
81
96
|
region=self.config.aws_region,
|
|
82
97
|
burst_mode=self._burst_mode,
|
|
98
|
+
wait_for_tasks=self._wait_for_tasks,
|
|
99
|
+
task_timeout=self._task_timeout,
|
|
83
100
|
)
|
|
84
101
|
|
|
85
102
|
async def start(self) -> Optional[BurstResult]:
|
|
@@ -116,10 +133,16 @@ class SQSTaskSource(TaskSource):
|
|
|
116
133
|
async def _run_burst_mode(self) -> BurstResult:
|
|
117
134
|
"""Run in burst mode: process all available messages then exit.
|
|
118
135
|
|
|
136
|
+
If wait_for_tasks is True (default), this will wait for all queued
|
|
137
|
+
tasks to complete before returning. This ensures that Docker jobs
|
|
138
|
+
(e.g., Claude executions) have finished, not just been queued.
|
|
139
|
+
|
|
119
140
|
Returns:
|
|
120
|
-
BurstResult with count of messages processed.
|
|
141
|
+
BurstResult with count of messages processed and tasks completed.
|
|
121
142
|
"""
|
|
122
|
-
logger.info("Running in burst mode - will drain queue and exit"
|
|
143
|
+
logger.info("Running in burst mode - will drain queue and exit",
|
|
144
|
+
wait_for_tasks=self._wait_for_tasks,
|
|
145
|
+
task_timeout=self._task_timeout)
|
|
123
146
|
|
|
124
147
|
# Track if we found any messages on the first poll
|
|
125
148
|
first_poll = True
|
|
@@ -135,7 +158,7 @@ class SQSTaskSource(TaskSource):
|
|
|
135
158
|
else:
|
|
136
159
|
# We've drained the queue
|
|
137
160
|
logger.info(
|
|
138
|
-
"
|
|
161
|
+
"SQS queue drained",
|
|
139
162
|
messages_processed=self._messages_processed,
|
|
140
163
|
errors=self._errors,
|
|
141
164
|
)
|
|
@@ -156,8 +179,37 @@ class SQSTaskSource(TaskSource):
|
|
|
156
179
|
exc_info=True,
|
|
157
180
|
)
|
|
158
181
|
|
|
182
|
+
# Now wait for all queued tasks to complete (if enabled)
|
|
183
|
+
if self._wait_for_tasks and self._messages_processed > 0:
|
|
184
|
+
container_pool = self.task_processor.container_pool
|
|
185
|
+
queued_count = container_pool.get_total_queued_tasks()
|
|
186
|
+
|
|
187
|
+
logger.info("SQS queue drained, waiting for container tasks to complete",
|
|
188
|
+
queued_tasks=queued_count,
|
|
189
|
+
timeout=self._task_timeout)
|
|
190
|
+
|
|
191
|
+
all_completed = await container_pool.wait_for_all_tasks_complete(
|
|
192
|
+
timeout=self._task_timeout
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if all_completed:
|
|
196
|
+
self._tasks_completed = self._messages_processed
|
|
197
|
+
logger.info("All container tasks completed successfully",
|
|
198
|
+
tasks_completed=self._tasks_completed)
|
|
199
|
+
else:
|
|
200
|
+
# Timeout occurred - some tasks may still be running
|
|
201
|
+
remaining = container_pool.get_total_queued_tasks()
|
|
202
|
+
self._tasks_completed = self._messages_processed - remaining
|
|
203
|
+
logger.warning("Timeout waiting for container tasks",
|
|
204
|
+
tasks_completed=self._tasks_completed,
|
|
205
|
+
tasks_remaining=remaining)
|
|
206
|
+
else:
|
|
207
|
+
# Not waiting for tasks, or no messages processed
|
|
208
|
+
self._tasks_completed = 0
|
|
209
|
+
|
|
159
210
|
return BurstResult(
|
|
160
211
|
messages_processed=self._messages_processed,
|
|
212
|
+
tasks_completed=self._tasks_completed,
|
|
161
213
|
errors=self._errors,
|
|
162
214
|
)
|
|
163
215
|
|