devs-webhook 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.
- devs_webhook/__init__.py +21 -0
- devs_webhook/app.py +321 -0
- devs_webhook/cli/__init__.py +6 -0
- devs_webhook/cli/worker.py +296 -0
- devs_webhook/config.py +319 -0
- devs_webhook/core/__init__.py +1 -0
- devs_webhook/core/claude_dispatcher.py +420 -0
- devs_webhook/core/container_pool.py +1109 -0
- devs_webhook/core/deduplication.py +113 -0
- devs_webhook/core/repository_manager.py +197 -0
- devs_webhook/core/task_processor.py +286 -0
- devs_webhook/core/test_dispatcher.py +448 -0
- devs_webhook/core/webhook_config.py +16 -0
- devs_webhook/core/webhook_handler.py +57 -0
- devs_webhook/github/__init__.py +15 -0
- devs_webhook/github/app_auth.py +226 -0
- devs_webhook/github/client.py +472 -0
- devs_webhook/github/models.py +424 -0
- devs_webhook/github/parser.py +325 -0
- devs_webhook/main_cli.py +386 -0
- devs_webhook/sources/__init__.py +7 -0
- devs_webhook/sources/base.py +24 -0
- devs_webhook/sources/sqs_source.py +306 -0
- devs_webhook/sources/webhook_source.py +82 -0
- devs_webhook/utils/__init__.py +1 -0
- devs_webhook/utils/async_utils.py +86 -0
- devs_webhook/utils/github.py +43 -0
- devs_webhook/utils/logging.py +34 -0
- devs_webhook/utils/serialization.py +102 -0
- devs_webhook-0.1.0.dist-info/METADATA +664 -0
- devs_webhook-0.1.0.dist-info/RECORD +35 -0
- devs_webhook-0.1.0.dist-info/WHEEL +5 -0
- devs_webhook-0.1.0.dist-info/entry_points.txt +3 -0
- devs_webhook-0.1.0.dist-info/licenses/LICENSE +21 -0
- devs_webhook-0.1.0.dist-info/top_level.txt +1 -0
devs_webhook/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""DevContainer Webhook Handler
|
|
2
|
+
|
|
3
|
+
A GitHub webhook handler for automated devcontainer operations with Claude Code.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__author__ = "Dan Lester"
|
|
8
|
+
|
|
9
|
+
from .config import WebhookConfig
|
|
10
|
+
from .core.webhook_handler import WebhookHandler
|
|
11
|
+
from .core.container_pool import ContainerPool
|
|
12
|
+
from .core.repository_manager import RepositoryManager
|
|
13
|
+
from .core.claude_dispatcher import ClaudeDispatcher
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"WebhookConfig",
|
|
17
|
+
"WebhookHandler",
|
|
18
|
+
"ContainerPool",
|
|
19
|
+
"RepositoryManager",
|
|
20
|
+
"ClaudeDispatcher",
|
|
21
|
+
]
|
devs_webhook/app.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""FastAPI webhook server.
|
|
2
|
+
|
|
3
|
+
This module provides the FastAPI application for receiving GitHub webhooks.
|
|
4
|
+
It's part of the webhook task source and delegates processing to WebhookHandler,
|
|
5
|
+
which in turn uses TaskProcessor for the core business logic.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
FastAPI endpoints -> WebhookHandler -> TaskProcessor -> ContainerPool
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import secrets
|
|
12
|
+
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, Depends, status
|
|
13
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
import structlog
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from .config import get_config, WebhookConfig
|
|
21
|
+
from .core.webhook_handler import WebhookHandler
|
|
22
|
+
from .utils.logging import setup_logging
|
|
23
|
+
from .utils.github import verify_github_signature
|
|
24
|
+
|
|
25
|
+
# Set up logging
|
|
26
|
+
setup_logging()
|
|
27
|
+
logger = structlog.get_logger()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestEventRequest(BaseModel):
|
|
31
|
+
"""Request model for test event endpoint."""
|
|
32
|
+
prompt: str
|
|
33
|
+
repo: str = "test/repo" # Default test repository
|
|
34
|
+
|
|
35
|
+
# Initialize FastAPI app
|
|
36
|
+
app = FastAPI(
|
|
37
|
+
title="DevContainer Webhook Handler",
|
|
38
|
+
description="GitHub webhook handler for automated devcontainer operations with Claude Code",
|
|
39
|
+
version="0.1.0"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Security setup for admin endpoints
|
|
43
|
+
security = HTTPBasic()
|
|
44
|
+
|
|
45
|
+
# Initialize webhook handler lazily
|
|
46
|
+
webhook_handler = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_webhook_handler():
|
|
50
|
+
"""Get or create the webhook handler."""
|
|
51
|
+
global webhook_handler
|
|
52
|
+
if webhook_handler is None:
|
|
53
|
+
webhook_handler = WebhookHandler()
|
|
54
|
+
return webhook_handler
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_dev_mode(config: WebhookConfig = Depends(get_config)):
|
|
58
|
+
"""Dependency that requires development mode."""
|
|
59
|
+
if not config.dev_mode:
|
|
60
|
+
raise HTTPException(
|
|
61
|
+
status_code=404,
|
|
62
|
+
detail="This endpoint is only available in development mode"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_admin_credentials(
|
|
67
|
+
credentials: HTTPBasicCredentials = Depends(security),
|
|
68
|
+
config: WebhookConfig = Depends(get_config)
|
|
69
|
+
):
|
|
70
|
+
"""Verify admin credentials for protected endpoints.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
credentials: HTTP Basic auth credentials
|
|
74
|
+
config: Webhook configuration
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Username if authentication successful
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HTTPException: If authentication fails
|
|
81
|
+
"""
|
|
82
|
+
# In dev mode with no password set, allow any credentials
|
|
83
|
+
if config.dev_mode and not config.admin_password:
|
|
84
|
+
logger.warning("Admin auth bypassed in dev mode without password")
|
|
85
|
+
return credentials.username
|
|
86
|
+
|
|
87
|
+
# Verify username and password
|
|
88
|
+
correct_username = secrets.compare_digest(
|
|
89
|
+
credentials.username, config.admin_username
|
|
90
|
+
)
|
|
91
|
+
correct_password = secrets.compare_digest(
|
|
92
|
+
credentials.password, config.admin_password
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not (correct_username and correct_password):
|
|
96
|
+
logger.warning(
|
|
97
|
+
"Failed admin authentication attempt",
|
|
98
|
+
username=credentials.username
|
|
99
|
+
)
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
102
|
+
detail="Invalid authentication credentials"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return credentials.username
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# verify_github_signature is now imported from .utils module
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.get("/")
|
|
112
|
+
async def root():
|
|
113
|
+
"""Health check endpoint."""
|
|
114
|
+
return {"status": "healthy", "service": "devs-webhook"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.get("/health")
|
|
118
|
+
async def health():
|
|
119
|
+
"""Health check endpoint."""
|
|
120
|
+
return {"status": "healthy", "service": "devs-webhook"}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.post("/webhook")
|
|
124
|
+
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
|
|
125
|
+
"""Handle GitHub webhook events."""
|
|
126
|
+
config = get_config()
|
|
127
|
+
|
|
128
|
+
# Get headers
|
|
129
|
+
headers = dict(request.headers)
|
|
130
|
+
|
|
131
|
+
# Read payload
|
|
132
|
+
payload = await request.body()
|
|
133
|
+
|
|
134
|
+
# Verify signature
|
|
135
|
+
signature = headers.get("x-hub-signature-256", "")
|
|
136
|
+
if not verify_github_signature(payload, signature, config.github_webhook_secret):
|
|
137
|
+
logger.warning("Invalid webhook signature", signature=signature)
|
|
138
|
+
raise HTTPException(status_code=401, detail="Invalid signature")
|
|
139
|
+
|
|
140
|
+
# Get event type
|
|
141
|
+
event_type = headers.get("x-github-event", "unknown")
|
|
142
|
+
delivery_id = headers.get("x-github-delivery", "unknown")
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
"Webhook received",
|
|
146
|
+
event_type=event_type,
|
|
147
|
+
delivery_id=delivery_id,
|
|
148
|
+
payload_size=len(payload)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Process webhook in background
|
|
152
|
+
background_tasks.add_task(
|
|
153
|
+
get_webhook_handler().process_webhook,
|
|
154
|
+
headers,
|
|
155
|
+
payload,
|
|
156
|
+
delivery_id
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return JSONResponse(
|
|
160
|
+
status_code=200,
|
|
161
|
+
content={"status": "accepted", "delivery_id": delivery_id}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.get("/status")
|
|
166
|
+
async def get_status(username: str = Depends(verify_admin_credentials)):
|
|
167
|
+
"""Get current webhook handler status.
|
|
168
|
+
|
|
169
|
+
Requires admin authentication.
|
|
170
|
+
"""
|
|
171
|
+
logger.info("Status endpoint accessed", authenticated_user=username)
|
|
172
|
+
return await get_webhook_handler().get_status()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.post("/container/{container_name}/stop")
|
|
176
|
+
async def stop_container(
|
|
177
|
+
container_name: str,
|
|
178
|
+
username: str = Depends(verify_admin_credentials)
|
|
179
|
+
):
|
|
180
|
+
"""Manually stop a container.
|
|
181
|
+
|
|
182
|
+
Requires admin authentication.
|
|
183
|
+
"""
|
|
184
|
+
logger.info(
|
|
185
|
+
"Container stop requested",
|
|
186
|
+
container=container_name,
|
|
187
|
+
authenticated_user=username
|
|
188
|
+
)
|
|
189
|
+
success = await get_webhook_handler().stop_container(container_name)
|
|
190
|
+
if success:
|
|
191
|
+
return {"status": "stopped", "container": container_name}
|
|
192
|
+
else:
|
|
193
|
+
raise HTTPException(status_code=404, detail="Container not found or failed to stop")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.get("/containers")
|
|
197
|
+
async def list_containers(username: str = Depends(verify_admin_credentials)):
|
|
198
|
+
"""List all managed containers.
|
|
199
|
+
|
|
200
|
+
Requires admin authentication.
|
|
201
|
+
"""
|
|
202
|
+
logger.info("Containers list accessed", authenticated_user=username)
|
|
203
|
+
return await get_webhook_handler().list_containers()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.post("/testevent")
|
|
207
|
+
async def test_event(
|
|
208
|
+
request: TestEventRequest,
|
|
209
|
+
config: WebhookConfig = Depends(require_dev_mode),
|
|
210
|
+
username: str = Depends(verify_admin_credentials)
|
|
211
|
+
):
|
|
212
|
+
"""Test endpoint to simulate GitHub webhook events with custom prompts.
|
|
213
|
+
|
|
214
|
+
Only available in development mode.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
POST /testevent
|
|
218
|
+
{
|
|
219
|
+
"prompt": "Fix the login bug in the authentication module",
|
|
220
|
+
"repo": "myorg/myproject"
|
|
221
|
+
}
|
|
222
|
+
"""
|
|
223
|
+
# Generate a unique delivery ID for this test
|
|
224
|
+
delivery_id = f"test-{uuid.uuid4().hex[:8]}"
|
|
225
|
+
|
|
226
|
+
logger.info(
|
|
227
|
+
"Test event received",
|
|
228
|
+
prompt_length=len(request.prompt),
|
|
229
|
+
repo=request.repo,
|
|
230
|
+
delivery_id=delivery_id
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Create a minimal mock webhook event
|
|
234
|
+
from .github.models import GitHubRepository, GitHubUser, GitHubIssue, TestIssueEvent
|
|
235
|
+
|
|
236
|
+
# Mock repository
|
|
237
|
+
mock_repo = GitHubRepository(
|
|
238
|
+
id=999999,
|
|
239
|
+
name=request.repo.split("/")[-1],
|
|
240
|
+
full_name=request.repo,
|
|
241
|
+
owner=GitHubUser(
|
|
242
|
+
login=request.repo.split("/")[0],
|
|
243
|
+
id=999999,
|
|
244
|
+
avatar_url="https://github.com/test.png",
|
|
245
|
+
html_url=f"https://github.com/{request.repo.split('/')[0]}"
|
|
246
|
+
),
|
|
247
|
+
html_url=f"https://github.com/{request.repo}",
|
|
248
|
+
clone_url=f"https://github.com/{request.repo}.git",
|
|
249
|
+
ssh_url=f"git@github.com:{request.repo}.git",
|
|
250
|
+
default_branch="main"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Mock issue with the prompt
|
|
254
|
+
mock_issue = GitHubIssue(
|
|
255
|
+
id=999999,
|
|
256
|
+
number=999,
|
|
257
|
+
title="Test Issue",
|
|
258
|
+
body=f"Test prompt: {request.prompt}",
|
|
259
|
+
state="open",
|
|
260
|
+
user=GitHubUser(
|
|
261
|
+
login="test-user",
|
|
262
|
+
id=999999,
|
|
263
|
+
avatar_url="https://github.com/test.png",
|
|
264
|
+
html_url="https://github.com/test-user"
|
|
265
|
+
),
|
|
266
|
+
html_url=f"https://github.com/{request.repo}/issues/999",
|
|
267
|
+
created_at=datetime.now(tz=timezone.utc),
|
|
268
|
+
updated_at=datetime.now(tz=timezone.utc)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Mock issue event
|
|
272
|
+
mock_event = TestIssueEvent(
|
|
273
|
+
action="opened",
|
|
274
|
+
issue=mock_issue,
|
|
275
|
+
repository=mock_repo,
|
|
276
|
+
sender=mock_issue.user
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Queue the task directly in the container pool
|
|
280
|
+
success = await get_webhook_handler().container_pool.queue_task(
|
|
281
|
+
task_id=delivery_id,
|
|
282
|
+
repo_name=request.repo,
|
|
283
|
+
task_description=request.prompt,
|
|
284
|
+
event=mock_event,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if success:
|
|
288
|
+
logger.info("Test task queued successfully",
|
|
289
|
+
delivery_id=delivery_id,
|
|
290
|
+
repo=request.repo)
|
|
291
|
+
|
|
292
|
+
return JSONResponse(
|
|
293
|
+
status_code=202,
|
|
294
|
+
content={
|
|
295
|
+
"status": "test_accepted",
|
|
296
|
+
"delivery_id": delivery_id,
|
|
297
|
+
"repo": request.repo,
|
|
298
|
+
"prompt": request.prompt[:100] + "..." if len(request.prompt) > 100 else request.prompt,
|
|
299
|
+
"message": "Test task queued for processing"
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
logger.error("Failed to queue test task",
|
|
304
|
+
delivery_id=delivery_id,
|
|
305
|
+
repo=request.repo)
|
|
306
|
+
|
|
307
|
+
raise HTTPException(
|
|
308
|
+
status_code=500,
|
|
309
|
+
detail="Failed to queue test task"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# Error handlers
|
|
314
|
+
@app.exception_handler(Exception)
|
|
315
|
+
async def global_exception_handler(request: Request, exc: Exception):
|
|
316
|
+
"""Global exception handler."""
|
|
317
|
+
logger.error("Unhandled exception", error=str(exc), path=request.url.path)
|
|
318
|
+
return JSONResponse(
|
|
319
|
+
status_code=500,
|
|
320
|
+
content={"error": "Internal server error", "detail": str(exc)}
|
|
321
|
+
)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Webhook worker CLI command for processing tasks in isolated subprocess."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
import click
|
|
7
|
+
import structlog
|
|
8
|
+
import asyncio
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from pydantic import TypeAdapter
|
|
14
|
+
from devs_common.core.project import Project
|
|
15
|
+
|
|
16
|
+
from ..config import get_config
|
|
17
|
+
from ..core.claude_dispatcher import ClaudeDispatcher
|
|
18
|
+
from ..core.test_dispatcher import TestDispatcher
|
|
19
|
+
from ..github.models import AnyWebhookEvent, DevsOptions
|
|
20
|
+
|
|
21
|
+
logger = structlog.get_logger()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command()
|
|
25
|
+
@click.option('--task-id', required=True, help='Unique task identifier')
|
|
26
|
+
@click.option('--dev-name', required=True, help='Development container name')
|
|
27
|
+
@click.option('--repo-name', required=True, help='Repository name (owner/repo)')
|
|
28
|
+
@click.option('--repo-path', required=True, help='Path to repository on host')
|
|
29
|
+
@click.option('--task-type', default='claude', help='Task type: claude or tests (default: claude)')
|
|
30
|
+
@click.option('--timeout', default=3600, help='Task timeout in seconds (default: 3600)')
|
|
31
|
+
def worker(task_id: str, dev_name: str, repo_name: str, repo_path: str, task_type: str, timeout: int):
|
|
32
|
+
"""Process a single webhook task in an isolated subprocess.
|
|
33
|
+
|
|
34
|
+
This command runs the complete task processing logic in a separate process to provide
|
|
35
|
+
Docker safety and prevent blocking the main web server.
|
|
36
|
+
|
|
37
|
+
Large payloads (task description, webhook event data, options) are read
|
|
38
|
+
from stdin as JSON to avoid command-line length limitations.
|
|
39
|
+
|
|
40
|
+
Expected stdin JSON format:
|
|
41
|
+
{
|
|
42
|
+
"task_description": "string",
|
|
43
|
+
"event": {...webhook event object...},
|
|
44
|
+
"devs_options": {...devs options object...} (optional)
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
# Set environment variable to redirect console output to stderr
|
|
48
|
+
os.environ['DEVS_WEBHOOK_MODE'] = '1'
|
|
49
|
+
|
|
50
|
+
# Configure structured logging for subprocess to output to stderr
|
|
51
|
+
import logging
|
|
52
|
+
logging.basicConfig(
|
|
53
|
+
stream=sys.stderr,
|
|
54
|
+
level=logging.INFO,
|
|
55
|
+
format='%(message)s'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
structlog.configure(
|
|
59
|
+
processors=[
|
|
60
|
+
structlog.stdlib.filter_by_level,
|
|
61
|
+
structlog.stdlib.add_logger_name,
|
|
62
|
+
structlog.stdlib.add_log_level,
|
|
63
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
64
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
65
|
+
structlog.processors.StackInfoRenderer(),
|
|
66
|
+
structlog.processors.format_exc_info,
|
|
67
|
+
structlog.processors.UnicodeDecoder(),
|
|
68
|
+
structlog.processors.JSONRenderer()
|
|
69
|
+
],
|
|
70
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
71
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
72
|
+
cache_logger_on_first_use=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
logger.info("Worker subprocess started",
|
|
76
|
+
task_id=task_id,
|
|
77
|
+
dev_name=dev_name,
|
|
78
|
+
repo_name=repo_name,
|
|
79
|
+
repo_path=repo_path,
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
pid=os.getpid())
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# Read payload from stdin
|
|
85
|
+
logger.info("Reading payload from stdin", task_id=task_id)
|
|
86
|
+
stdin_data = sys.stdin.read()
|
|
87
|
+
|
|
88
|
+
if not stdin_data:
|
|
89
|
+
raise ValueError("No data provided on stdin")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
payload = json.loads(stdin_data)
|
|
93
|
+
except json.JSONDecodeError as e:
|
|
94
|
+
raise ValueError(f"Invalid JSON on stdin: {e}")
|
|
95
|
+
|
|
96
|
+
# Extract required fields
|
|
97
|
+
task_description = payload.get('task_description')
|
|
98
|
+
event_data = payload.get('event')
|
|
99
|
+
devs_options_data = payload.get('devs_options')
|
|
100
|
+
|
|
101
|
+
# task_description is only required for claude tasks, not for tests
|
|
102
|
+
if task_type == 'claude' and not task_description:
|
|
103
|
+
raise ValueError("task_description required in stdin JSON for claude tasks")
|
|
104
|
+
if not event_data:
|
|
105
|
+
raise ValueError("event required in stdin JSON")
|
|
106
|
+
|
|
107
|
+
# Parse webhook event directly from JSON - let Pydantic figure out the type!
|
|
108
|
+
logger.info("Parsing webhook event from JSON", task_id=task_id)
|
|
109
|
+
|
|
110
|
+
# Use TypeAdapter to handle the union type automatically
|
|
111
|
+
webhook_adapter = TypeAdapter(AnyWebhookEvent)
|
|
112
|
+
event = webhook_adapter.validate_python(event_data)
|
|
113
|
+
|
|
114
|
+
parsed_devs_options = None
|
|
115
|
+
if devs_options_data:
|
|
116
|
+
logger.info("Parsing devs options from JSON", task_id=task_id)
|
|
117
|
+
parsed_devs_options = DevsOptions.model_validate(devs_options_data)
|
|
118
|
+
|
|
119
|
+
# Run the task processing logic (extracted from ContainerPool._process_task)
|
|
120
|
+
result = _process_task_subprocess(
|
|
121
|
+
task_id=task_id,
|
|
122
|
+
dev_name=dev_name,
|
|
123
|
+
repo_name=repo_name,
|
|
124
|
+
repo_path=Path(repo_path),
|
|
125
|
+
task_description=task_description,
|
|
126
|
+
event=event,
|
|
127
|
+
devs_options=parsed_devs_options,
|
|
128
|
+
task_type=task_type
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Output result as JSON to stdout
|
|
132
|
+
output = {
|
|
133
|
+
'success': result['success'],
|
|
134
|
+
'output': result.get('output', ''),
|
|
135
|
+
'error': result.get('error'),
|
|
136
|
+
'task_id': task_id,
|
|
137
|
+
'timestamp': datetime.now(tz=timezone.utc).isoformat()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
print(json.dumps(output))
|
|
141
|
+
sys.exit(0 if result['success'] else 1)
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
error_msg = f"Worker subprocess failed: {str(e)}"
|
|
145
|
+
logger.error("Worker subprocess error",
|
|
146
|
+
task_id=task_id,
|
|
147
|
+
error=error_msg,
|
|
148
|
+
exc_info=True)
|
|
149
|
+
|
|
150
|
+
# Output error as JSON to stdout
|
|
151
|
+
error_output = {
|
|
152
|
+
'success': False,
|
|
153
|
+
'output': '',
|
|
154
|
+
'error': error_msg,
|
|
155
|
+
'task_id': task_id,
|
|
156
|
+
'timestamp': datetime.now(tz=timezone.utc).isoformat()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
print(json.dumps(error_output))
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _process_task_subprocess(
|
|
164
|
+
task_id: str,
|
|
165
|
+
dev_name: str,
|
|
166
|
+
repo_name: str,
|
|
167
|
+
repo_path: Path,
|
|
168
|
+
task_description: Optional[str],
|
|
169
|
+
event,
|
|
170
|
+
devs_options,
|
|
171
|
+
task_type: str = 'claude'
|
|
172
|
+
) -> dict:
|
|
173
|
+
"""Process a single task in subprocess (extracted from ContainerPool._process_task).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
task_id: Unique task identifier
|
|
177
|
+
dev_name: Name of container to execute in
|
|
178
|
+
repo_name: Repository name (owner/repo)
|
|
179
|
+
repo_path: Path to repository on host
|
|
180
|
+
task_description: Task description for Claude (unused for tests, can be None for test tasks)
|
|
181
|
+
event: WebhookEvent instance
|
|
182
|
+
devs_options: DevsOptions instance
|
|
183
|
+
task_type: Task type ('claude' or 'tests')
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Dict with 'success', 'output', and optionally 'error' keys
|
|
187
|
+
"""
|
|
188
|
+
logger.info("Starting task processing in subprocess",
|
|
189
|
+
task_id=task_id,
|
|
190
|
+
dev_name=dev_name,
|
|
191
|
+
repo_name=repo_name,
|
|
192
|
+
repo_path=str(repo_path),
|
|
193
|
+
task_type=task_type)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
# Verify repository exists (should already be cloned by container_pool)
|
|
197
|
+
if not repo_path.exists():
|
|
198
|
+
raise Exception(f"Repository path does not exist: {repo_path}")
|
|
199
|
+
|
|
200
|
+
# Create project instance
|
|
201
|
+
logger.info("Creating project instance",
|
|
202
|
+
task_id=task_id,
|
|
203
|
+
dev_name=dev_name,
|
|
204
|
+
repo_path=str(repo_path),
|
|
205
|
+
task_type=task_type)
|
|
206
|
+
|
|
207
|
+
project = Project(repo_path)
|
|
208
|
+
workspace_name = project.get_workspace_name(dev_name)
|
|
209
|
+
|
|
210
|
+
logger.info("Project created successfully",
|
|
211
|
+
task_id=task_id,
|
|
212
|
+
dev_name=dev_name,
|
|
213
|
+
project_name=project.info.name,
|
|
214
|
+
workspace_name=workspace_name,
|
|
215
|
+
task_type=task_type)
|
|
216
|
+
|
|
217
|
+
# Initialize appropriate dispatcher based on task type
|
|
218
|
+
if task_type == 'tests':
|
|
219
|
+
dispatcher = TestDispatcher()
|
|
220
|
+
logger.info("Executing task with Test dispatcher",
|
|
221
|
+
task_id=task_id,
|
|
222
|
+
dev_name=dev_name,
|
|
223
|
+
workspace_name=workspace_name)
|
|
224
|
+
|
|
225
|
+
# Execute tests
|
|
226
|
+
result = asyncio.run(dispatcher.execute_tests(
|
|
227
|
+
dev_name=dev_name,
|
|
228
|
+
repo_path=repo_path,
|
|
229
|
+
event=event,
|
|
230
|
+
devs_options=devs_options
|
|
231
|
+
))
|
|
232
|
+
else:
|
|
233
|
+
# Default to Claude execution
|
|
234
|
+
dispatcher = ClaudeDispatcher()
|
|
235
|
+
logger.info("Executing task with Claude dispatcher",
|
|
236
|
+
task_id=task_id,
|
|
237
|
+
dev_name=dev_name,
|
|
238
|
+
workspace_name=workspace_name)
|
|
239
|
+
|
|
240
|
+
# Ensure task_description is provided for Claude tasks
|
|
241
|
+
if not task_description:
|
|
242
|
+
raise ValueError("task_description is required for Claude tasks")
|
|
243
|
+
|
|
244
|
+
# Execute Claude task
|
|
245
|
+
result = asyncio.run(dispatcher.execute_task(
|
|
246
|
+
dev_name=dev_name,
|
|
247
|
+
repo_path=repo_path,
|
|
248
|
+
task_description=task_description,
|
|
249
|
+
event=event,
|
|
250
|
+
devs_options=devs_options
|
|
251
|
+
))
|
|
252
|
+
|
|
253
|
+
if result.success:
|
|
254
|
+
logger.info("Task execution completed successfully",
|
|
255
|
+
task_id=task_id,
|
|
256
|
+
dev_name=dev_name,
|
|
257
|
+
output_length=len(result.output) if result.output else 0)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
'success': True,
|
|
261
|
+
'output': result.output,
|
|
262
|
+
}
|
|
263
|
+
else:
|
|
264
|
+
logger.error("Task execution failed",
|
|
265
|
+
task_id=task_id,
|
|
266
|
+
dev_name=dev_name,
|
|
267
|
+
error=result.error,
|
|
268
|
+
output_preview=result.output[:500] if result.output else "")
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
'success': False,
|
|
272
|
+
'output': result.output or '',
|
|
273
|
+
'error': result.error
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
error_msg = f"Task processing failed: {str(e)}"
|
|
278
|
+
logger.error("Task processing error in subprocess",
|
|
279
|
+
task_id=task_id,
|
|
280
|
+
dev_name=dev_name,
|
|
281
|
+
repo_name=repo_name,
|
|
282
|
+
repo_path=str(repo_path),
|
|
283
|
+
error=error_msg,
|
|
284
|
+
error_type=type(e).__name__,
|
|
285
|
+
exc_info=True)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
'success': False,
|
|
289
|
+
'output': '',
|
|
290
|
+
'error': error_msg
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == '__main__':
|
|
295
|
+
import os
|
|
296
|
+
worker()
|