diff --git a/backend/README.md b/backend/README.md index b54202c..e737a83 100644 --- a/backend/README.md +++ b/backend/README.md @@ -37,6 +37,27 @@ docker-compose exec app uv run alembic upgrade head docker-compose exec app uv run alembic history ``` +## デプロイ手順 + +Backend と Worker の変更を反映する手順です。 + +### Backend (FastAPI) の再起動 +```bash +cd backend +docker compose restart app +``` + +### Worker イメージの再ビルド・K8s ロード +```bash +# 1. イメージをビルド +docker build -t ui-recommender-worker:latest -f docker/worker.Dockerfile . + +# 2. Docker Desktop の組み込み K8s にロード +docker save ui-recommender-worker:latest | docker exec -i desktop-control-plane ctr -n k8s.io images import - +``` + +> **Note:** Worker Pod は `imagePullPolicy: Never` で運用しているため、レジストリへの push は不要です。 + ## 技術スタック - Python 3.13 - FastAPI 0.129.0 diff --git a/backend/app/infra/k8s_client.py b/backend/app/infra/k8s_client.py index 90585dc..1dde7e5 100644 --- a/backend/app/infra/k8s_client.py +++ b/backend/app/infra/k8s_client.py @@ -49,6 +49,7 @@ def _build_worker_container( ), ), client.V1EnvVar(name="TERM", value="dumb"), + client.V1EnvVar(name="CLAUDE_CODE_MAX_OUTPUT_TOKENS", value="128000"), ] return client.V1Container( name="worker", @@ -292,6 +293,7 @@ def _build_session_worker_container( ), ), client.V1EnvVar(name="TERM", value="dumb"), + client.V1EnvVar(name="CLAUDE_CODE_MAX_OUTPUT_TOKENS", value="128000"), ] + self._build_s3_env_vars() return client.V1Container( name="worker", @@ -461,7 +463,7 @@ def create_session_pr_job( return job_name async def stream_pod_logs( - self, job_name: str, tail_lines: int = 100, since_seconds: int | None = None + self, job_name: str, tail_lines: int = 1000, since_seconds: int | None = None ) -> AsyncIterator[str]: """Stream logs from a job's pod. Yields log lines as they arrive. diff --git a/backend/app/infra/s3_client.py b/backend/app/infra/s3_client.py index c87212e..9d13431 100644 --- a/backend/app/infra/s3_client.py +++ b/backend/app/infra/s3_client.py @@ -129,15 +129,6 @@ def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str | None # ── High-level artifact accessors ── - def get_proposals(self, session_id: str, iteration_index: int) -> list[dict[str, Any]] | None: - data = self.download_json(self.proposals_json_key(session_id, iteration_index)) - if isinstance(data, dict) and "proposals" in data: - result: list[dict[str, Any]] = data["proposals"] - return result - if isinstance(data, list): - return data - return None - def get_diff(self, session_id: str, iteration_index: int, proposal_index: int) -> str | None: return self.download_text(self.diff_key(session_id, iteration_index, proposal_index)) diff --git a/backend/app/usecase/session_usecase.py b/backend/app/usecase/session_usecase.py index 15d64c0..04f31a4 100644 --- a/backend/app/usecase/session_usecase.py +++ b/backend/app/usecase/session_usecase.py @@ -308,10 +308,12 @@ async def _run_session_analysis( "error": None, "proposals": None, "before_screenshot_key": None, + "device_type": None, } ) if result.get("proposals"): + device_type = result.get("device_type", "desktop") proposals = [] for i, prop in enumerate(result["proposals"]): p = Proposal( @@ -333,10 +335,11 @@ async def _run_session_analysis( before_screenshot_key=result.get("before_screenshot_key"), ) logger.info( - "Session %s iter %d analysis done: %d proposals", + "Session %s iter %d analysis done: %d proposals (device: %s)", session_id, iteration_index, len(proposals), + device_type, ) # Auto-trigger implementation for ALL proposals @@ -355,6 +358,7 @@ async def _run_session_analysis( proposal_id=str(proposal.id), plan_json=str(proposal.plan), selected_proposal_index=selected_proposal_index, + device_type=device_type, ) ) else: @@ -394,6 +398,7 @@ async def _run_session_implementation( proposal_id: str, plan_json: str, selected_proposal_index: int | None, + device_type: str = "desktop", ) -> None: """Background: run one session-based implementation workflow.""" db = SessionLocal() @@ -417,6 +422,7 @@ async def _run_session_implementation( "branch": branch, "proposal_index": proposal_index, "proposal_plan": plan_json, + "device_type": device_type, "selected_proposal_index": selected_proposal_index, "k8s_job_name": None, "status": "pending", diff --git a/backend/app/workflow/session_analyzer_graph.py b/backend/app/workflow/session_analyzer_graph.py index ea416aa..9cc4290 100644 --- a/backend/app/workflow/session_analyzer_graph.py +++ b/backend/app/workflow/session_analyzer_graph.py @@ -57,17 +57,24 @@ async def extract_results(state: SessionAnalyzerState) -> dict: session_id = state["session_id"] iteration_index = state["iteration_index"] - proposals = s3.get_proposals(session_id, iteration_index) - before_key = s3.before_screenshot_key(session_id, iteration_index) - has_before = s3.exists(before_key) - - if proposals is None: + data = s3.download_json(s3.proposals_json_key(session_id, iteration_index)) + if isinstance(data, dict) and "proposals" in data: + proposals = data["proposals"] + device_type = data.get("device_type", "desktop") + elif isinstance(data, list): + proposals = data + device_type = "desktop" + else: return {"error": "Failed to read proposals from S3", "status": "failed"} + if len(proposals) == 0: - return {"error": "No proposals generated by analyzer", "status": "failed"} + return {"error": "No proposals generated", "status": "failed"} + + before_key = s3.before_screenshot_key(session_id, iteration_index) return { "proposals": proposals, - "before_screenshot_key": before_key if has_before else None, + "device_type": device_type, + "before_screenshot_key": before_key if s3.exists(before_key) else None, } diff --git a/backend/app/workflow/session_implementation_graph.py b/backend/app/workflow/session_implementation_graph.py index 2cdcfdd..3597784 100644 --- a/backend/app/workflow/session_implementation_graph.py +++ b/backend/app/workflow/session_implementation_graph.py @@ -1,3 +1,4 @@ +import json as _json import logging from typing import Any @@ -15,9 +16,16 @@ async def create_k8s_job(state: SessionImplementationState) -> dict: k8s = K8sClient() s3 = S3Client() - # Write proposal plan to S3 for the worker to read + # Write proposal plan to S3 for the worker to read (includes device_type) plan_key = s3.plan_key(state["session_id"], state["iteration_index"], state["proposal_index"]) - s3.upload_text(plan_key, state["proposal_plan"]) + plan_with_device = _json.dumps( + { + "plan": state["proposal_plan"], + "device_type": state.get("device_type", "desktop"), + }, + ensure_ascii=False, + ) + s3.upload_text(plan_key, plan_with_device) job_name = k8s.create_session_implementation_job( session_id=state["session_id"], diff --git a/backend/app/workflow/state.py b/backend/app/workflow/state.py index 75dd7c0..a2ad5ae 100644 --- a/backend/app/workflow/state.py +++ b/backend/app/workflow/state.py @@ -23,6 +23,7 @@ class SessionAnalyzerState(TypedDict): # Output proposals: list[dict] | None before_screenshot_key: str | None + device_type: str | None class SessionImplementationState(TypedDict): @@ -35,6 +36,7 @@ class SessionImplementationState(TypedDict): branch: str proposal_index: int proposal_plan: str + device_type: str # Patch context (for iter > 0) selected_proposal_index: int | None diff --git a/docker/take-screenshot.mjs b/docker/take-screenshot.mjs deleted file mode 100644 index fe55127..0000000 --- a/docker/take-screenshot.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { chromium } from 'playwright'; - -const targetUrl = process.argv[2] || 'http://localhost:5173'; -const outputPath = process.argv[3] || '/workspace/screenshot.png'; - -(async () => { - const browser = await chromium.launch(); - const page = await browser.newPage(); - await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 }); - await page.waitForTimeout(2000); - await page.screenshot({ path: outputPath, fullPage: true }); - await browser.close(); - console.log(`Screenshot saved to ${outputPath}`); -})(); diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index 3a1bf86..9342271 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -17,6 +17,32 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, query, AssistantMessage, TextBlock, ToolUseBlock from claude_agent_sdk.types import StreamEvent +PLAYWRIGHT_MCP_SERVERS = { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium"], + }, + "playwright_mobile": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium", "--device", "iPhone 15"], + }, +} + +PLAYWRIGHT_MCP_TOOLS = [ + "mcp__playwright__browser_navigate", + "mcp__playwright__browser_snapshot", + "mcp__playwright__browser_take_screenshot", + "mcp__playwright__browser_wait_for", + "mcp__playwright__browser_evaluate", + "mcp__playwright__browser_console_messages", + "mcp__playwright_mobile__browser_navigate", + "mcp__playwright_mobile__browser_snapshot", + "mcp__playwright_mobile__browser_take_screenshot", + "mcp__playwright_mobile__browser_wait_for", + "mcp__playwright_mobile__browser_evaluate", + "mcp__playwright_mobile__browser_console_messages", +] + def emit_log(phase: str, message: str, detail: str | None = None) -> None: entry = { @@ -132,14 +158,15 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: _emit_tool_detail(phase, block.name, json.dumps(block.input)) -async def launch_and_screenshot(repo_dir: str, screenshot_output: str) -> None: +async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop") -> None: """Launch dev server and take screenshot in a single session. Uses ClaudeSDKClient to keep the same conversation context across two query() calls so the agent remembers the dev server URL. """ options = ClaudeAgentOptions( - allowed_tools=["Read", "Bash", "Glob", "Grep"], + allowed_tools=["Read", "Bash", "Glob", "Grep"] + PLAYWRIGHT_MCP_TOOLS, + mcp_servers=PLAYWRIGHT_MCP_SERVERS, cwd=repo_dir, max_turns=20, max_budget_usd=1.0, @@ -157,26 +184,39 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str) -> None: 3. Start the dev server in the background using Bash (e.g. `npm run dev &` or whatever is appropriate) 4. Wait for the server to become ready (poll with curl until it responds) -IMPORTANT: Make sure the dev server is running and responding before finishing. +IMPORTANT: +- Playwright and Chromium are already installed globally. Do NOT run `npx playwright install` or any Playwright installation commands. +- Only install the PROJECT's dependencies (e.g. `npm install` in the project directory). +- Make sure the dev server is running and responding before finishing. """) await _process_messages(client, "launching") # Phase 2: take screenshot (same session, so dev server URL is remembered) emit_log("screenshot", "Taking before screenshot") + device = "playwright_mobile" if device_type == "mobile" else "playwright" await client.query(f"""Now take a screenshot of the running application. -You already know the dev server URL from the previous step. Use it to take a screenshot: -1. Run: `node /usr/local/bin/take-screenshot.mjs {screenshot_output}` - where is the URL the dev server is listening on -2. Verify the screenshot file was created at {screenshot_output} +You already know the dev server URL from the previous step. + +Use the **{device}** browser tools (mcp__{device}__*) to take the screenshot. -NOTE: Playwright and Chromium are pre-installed globally in this container. Do NOT install playwright or run `npx playwright install`. Just run `node /usr/local/bin/take-screenshot.mjs ` directly. +Steps: +1. Use mcp__{device}__browser_navigate to open the dev server URL +2. Use mcp__{device}__browser_wait_for to wait for the page to fully load +3. Use mcp__{device}__browser_take_screenshot to capture the viewport and save to {screenshot_output} + +IMPORTANT: +- Do NOT use fullPage. Capture only what the user actually sees in the viewport. +- If a specific element needs to be visible, use mcp__{device}__browser_evaluate to scrollIntoView first. """) await _process_messages(client, "screenshot") -def _extract_proposals_json(collected_text: list[str]) -> list[dict]: - """Extract proposals JSON from Claude Agent output, trying multiple strategies.""" +def _extract_proposals_json(collected_text: list[str]) -> dict: + """Extract proposals JSON from Claude Agent output, trying multiple strategies. + + Returns a dict with "device_type" and "proposals" keys. + """ # Strategy 1: Try each text block from the end (last output is most likely the JSON) for text in reversed(collected_text): @@ -193,14 +233,16 @@ def _extract_proposals_json(collected_text: list[str]) -> list[dict]: try: data = json.loads(text) if isinstance(data, dict) and "proposals" in data: - return data["proposals"] - if isinstance(data, list): return data + if isinstance(data, list): + return {"device_type": "desktop", "proposals": data} except json.JSONDecodeError: pass # If block contains JSON embedded after prose, find the first '{' and try parsing from there brace_idx = text.find('{"proposals"') + if brace_idx == -1: + brace_idx = text.find('{"device_type"') if brace_idx == -1: brace_idx = text.find("{\n") if brace_idx >= 0: @@ -208,21 +250,22 @@ def _extract_proposals_json(collected_text: list[str]) -> list[dict]: try: data = json.loads(candidate) if isinstance(data, dict) and "proposals" in data: - return data["proposals"] + return data except json.JSONDecodeError: pass - # Strategy 2: Find {"proposals" in concatenated text and use JSONDecoder to parse + # Strategy 2: Find JSON object in concatenated text and use JSONDecoder to parse full_text = "\n".join(collected_text) decoder = json.JSONDecoder() - idx = full_text.find('{"proposals"') - if idx >= 0: - try: - data, _ = decoder.raw_decode(full_text, idx) - if isinstance(data, dict) and "proposals" in data: - return data["proposals"] - except json.JSONDecodeError: - pass + for marker in ['{"device_type"', '{"proposals"']: + idx = full_text.find(marker) + if idx >= 0: + try: + data, _ = decoder.raw_decode(full_text, idx) + if isinstance(data, dict) and "proposals" in data: + return data + except json.JSONDecodeError: + pass # Strategy 3: Extract from markdown code blocks in full text for pattern in [r'```json\s*(.*?)```', r'```\s*(.*?)```']: @@ -231,9 +274,9 @@ def _extract_proposals_json(collected_text: list[str]) -> list[dict]: try: data = json.loads(m.group(1).strip()) if isinstance(data, dict) and "proposals" in data: - return data["proposals"] - if isinstance(data, list): return data + if isinstance(data, list): + return {"device_type": "desktop", "proposals": data} except json.JSONDecodeError: continue @@ -242,13 +285,16 @@ def _extract_proposals_json(collected_text: list[str]) -> list[dict]: for i, t in enumerate(collected_text): print(f"Block {i}: {t[:200]}", file=sys.stderr) - return [] + return {"device_type": "desktop", "proposals": []} async def generate_proposals( repo_dir: str, instruction: str, num_proposals: int -) -> list[dict]: - """Use Claude Agent SDK to analyze the repo and generate design proposals.""" +) -> dict: + """Use Claude Agent SDK to analyze the repo and generate design proposals. + + Returns a dict with "device_type" and "proposals" keys. + """ prompt = f"""You are a UI/UX design expert analyzing a web application repository. The user wants the following UI changes: @@ -256,17 +302,23 @@ async def generate_proposals( ## Your Task -1. First, analyze the codebase using the available tools (Read, Glob, Grep) to understand the project structure, framework, and relevant files. -2. After sufficient analysis, generate exactly {num_proposals} different design proposals. -3. Each proposal should take a meaningfully different approach. +1. Analyze the codebase using the available tools (Read, Glob, Grep) to understand the project structure, framework, and relevant files. +2. Determine the project's target platform: + - Check package.json for mobile frameworks (react-native, @capacitor, @ionic, expo) + - Check HTML viewport meta tag and CSS media queries + - If the project targets mobile or is mobile-first → set device_type to "mobile" + - Otherwise → set device_type to "desktop" +3. Generate exactly {num_proposals} different design proposals, each taking a meaningfully different approach. ## Output Format IMPORTANT: After your analysis, you MUST output EXACTLY ONE valid JSON object as your FINAL message. Do not include any text before or after the JSON. Do not wrap it in markdown code blocks. +Include "device_type" at the top level of the JSON. The JSON must follow this exact structure: {{ + "device_type": "desktop|mobile", "proposals": [ {{ "title": "Short title (under 50 chars)", @@ -391,16 +443,27 @@ async def main() -> None: file=sys.stderr, ) - # Step 2-3: Launch project and take before screenshot (single session) - before_path = f"{tmp_dir}/before.png" - await launch_and_screenshot(repo_dir, before_path) - - # Step 4: Generate design proposals via Claude Agent SDK + # Step 2: Code analysis + device detection + proposal generation (no browser needed) emit_log("analyzing", "Generating design proposals") - proposals = await generate_proposals(repo_dir, instruction, num_proposals) - emit_log("analyzing", f"Generated {len(proposals)} proposals") + try: + result = await generate_proposals(repo_dir, instruction, num_proposals) + except Exception as e: + emit_log("analyzing", f"Analysis interrupted: {e}", detail=str(e)) + result = {"device_type": "desktop", "proposals": []} + + proposals = result.get("proposals", []) + device_type = result.get("device_type", "desktop") + if isinstance(device_type, str): + device_type = device_type.lower() + if device_type not in ("desktop", "mobile"): + device_type = "desktop" + emit_log("analyzing", f"Generated {len(proposals)} proposals (device: {device_type})") + + # Step 3: Launch dev server + take before screenshot (with determined device_type) + before_path = f"{tmp_dir}/before.png" + await launch_and_screenshot(repo_dir, before_path, device_type=device_type) - # Step 5: Upload results to S3 + # Step 4: Upload results to S3 emit_log("uploading", "Uploading results to S3") # Upload before screenshot @@ -412,8 +475,8 @@ async def main() -> None: content_type="image/png", ) - # Upload proposals.json - proposals_data = {"proposals": proposals} + # Upload proposals.json (includes device_type) + proposals_data = {"device_type": device_type, "proposals": proposals} s3_upload_text( s3, bucket, f"{s3_prefix}/proposals.json", diff --git a/docker/worker-implement.py b/docker/worker-implement.py index 25a6e59..7fb8b0b 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -17,6 +17,32 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, query, AssistantMessage, TextBlock, ToolUseBlock from claude_agent_sdk.types import StreamEvent +PLAYWRIGHT_MCP_SERVERS = { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium"], + }, + "playwright_mobile": { + "command": "npx", + "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium", "--device", "iPhone 15"], + }, +} + +PLAYWRIGHT_MCP_TOOLS = [ + "mcp__playwright__browser_navigate", + "mcp__playwright__browser_snapshot", + "mcp__playwright__browser_take_screenshot", + "mcp__playwright__browser_wait_for", + "mcp__playwright__browser_evaluate", + "mcp__playwright__browser_console_messages", + "mcp__playwright_mobile__browser_navigate", + "mcp__playwright_mobile__browser_snapshot", + "mcp__playwright_mobile__browser_take_screenshot", + "mcp__playwright_mobile__browser_wait_for", + "mcp__playwright_mobile__browser_evaluate", + "mcp__playwright_mobile__browser_console_messages", +] + def emit_log(phase: str, message: str, detail: str | None = None) -> None: entry = { @@ -197,16 +223,18 @@ async def kill_dev_servers(repo_dir: str) -> None: await asyncio.sleep(2) -async def fix_with_claude(repo_dir: str, error_message: str) -> None: +async def fix_with_claude(repo_dir: str, error_message: str, device_type: str = "desktop") -> None: """Use Claude to diagnose and fix the dev server launch failure.""" options = ClaudeAgentOptions( - allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"], + allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"] + PLAYWRIGHT_MCP_TOOLS, + mcp_servers=PLAYWRIGHT_MCP_SERVERS, cwd=repo_dir, max_turns=20, max_budget_usd=1.5, include_partial_messages=True, ) + device = "playwright_mobile" if device_type == "mobile" else "playwright" async with ClaudeSDKClient(options=options) as client: await client.query(f"""The dev server failed to start in the project at {repo_dir}. @@ -216,7 +244,13 @@ async def fix_with_claude(repo_dir: str, error_message: str) -> None: {error_message} ``` -Please diagnose and fix the issue. Common causes include: +Please diagnose and fix the issue: +1. Use mcp__{device}__browser_console_messages to check for JavaScript errors +2. Use mcp__{device}__browser_navigate and mcp__{device}__browser_snapshot to check the page state +3. Read source files to identify root cause +4. Fix the code. Make minimal changes. + +Common causes include: - Missing or incorrect dependencies (run `npm install` or similar) - Build errors in the source code (syntax errors, import errors, type errors) - Port conflicts (change the port configuration) @@ -227,14 +261,15 @@ async def fix_with_claude(repo_dir: str, error_message: str) -> None: await _process_messages(client, "implementing") -async def launch_and_screenshot(repo_dir: str, screenshot_output: str) -> None: +async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop") -> None: """Launch dev server and take screenshot in a single session. Uses ClaudeSDKClient to keep the same conversation context across two query() calls so the agent remembers the dev server URL. """ options = ClaudeAgentOptions( - allowed_tools=["Read", "Bash", "Glob", "Grep"], + allowed_tools=["Read", "Bash", "Glob", "Grep"] + PLAYWRIGHT_MCP_TOOLS, + mcp_servers=PLAYWRIGHT_MCP_SERVERS, cwd=repo_dir, max_turns=20, max_budget_usd=1.0, @@ -252,20 +287,30 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str) -> None: 3. Start the dev server in the background using Bash (e.g. `npm run dev &` or whatever is appropriate) 4. Wait for the server to become ready (poll with curl until it responds) -IMPORTANT: Make sure the dev server is running and responding before finishing. +IMPORTANT: +- Playwright and Chromium are already installed globally. Do NOT run `npx playwright install` or any Playwright installation commands. +- Only install the PROJECT's dependencies (e.g. `npm install` in the project directory). +- Make sure the dev server is running and responding before finishing. """) await _process_messages(client, "launching") # Phase 2: take screenshot (same session, so dev server URL is remembered) emit_log("screenshot", "Taking after screenshot") + device = "playwright_mobile" if device_type == "mobile" else "playwright" await client.query(f"""Now take a screenshot of the running application. -You already know the dev server URL from the previous step. Use it to take a screenshot: -1. Run: `node /usr/local/bin/take-screenshot.mjs {screenshot_output}` - where is the URL the dev server is listening on -2. Verify the screenshot file was created at {screenshot_output} +You already know the dev server URL from the previous step. + +Use the **{device}** browser tools (mcp__{device}__*) to take the screenshot. -NOTE: Playwright and Chromium are pre-installed globally in this container. Do NOT install playwright or run `npx playwright install`. Just run `node /usr/local/bin/take-screenshot.mjs ` directly. +Steps: +1. Use mcp__{device}__browser_navigate to open the dev server URL +2. Use mcp__{device}__browser_wait_for to wait for the page to fully load +3. Use mcp__{device}__browser_take_screenshot to capture the viewport and save to {screenshot_output} + +IMPORTANT: +- Do NOT use fullPage. Capture only what the user actually sees in the viewport. +- If a specific element needs to be visible, use mcp__{device}__browser_evaluate to scrollIntoView first. """) await _process_messages(client, "screenshot") @@ -301,11 +346,31 @@ async def main() -> None: plan_key = f"{s3_prefix}/plan.json" local_plan = f"{tmp_dir}/plan.json" emit_log("cloning", f"Downloading plan from S3: {plan_key}") + device_type = "desktop" if s3_download(s3, bucket, plan_key, local_plan): - proposal_plan = Path(local_plan).read_text() + raw = Path(local_plan).read_text() + try: + plan_data = json.loads(raw) + proposal_plan = plan_data.get("plan", raw) + device_type = plan_data.get("device_type", "desktop") + if isinstance(device_type, str): + device_type = device_type.lower() + if device_type not in ("desktop", "mobile"): + device_type = "desktop" + except json.JSONDecodeError: + proposal_plan = raw # backward compat else: # Fallback to env var (for backward compat during transition) proposal_plan = os.environ.get("PROPOSAL_PLAN", "") + emit_log("setup", f"Using device type: {device_type}") + + # Log loaded proposal details + try: + _plan = json.loads(proposal_plan) if isinstance(proposal_plan, str) else proposal_plan + title = _plan.get("title", f"proposal-{proposal_index}") + except (json.JSONDecodeError, AttributeError): + title = f"proposal-{proposal_index}" + emit_log("setup", f"Loaded proposal {proposal_index}: {title}") if not proposal_plan: print("Error: No proposal plan provided", file=sys.stderr) @@ -366,14 +431,17 @@ async def main() -> None: screenshot_path = f"{tmp_dir}/after.png" emit_log("implementing", "Implementing design proposal") - await implement_changes(repo_dir, proposal_plan) + try: + await implement_changes(repo_dir, proposal_plan) + except Exception as e: + emit_log("implementing", f"Implementation interrupted: {e}", detail=str(e)) # Launch + screenshot with retry loop max_retries = 2 # up to 3 total attempts last_error = None for attempt in range(max_retries + 1): try: - await launch_and_screenshot(repo_dir, screenshot_path) + await launch_and_screenshot(repo_dir, screenshot_path, device_type=device_type) last_error = None break # success except Exception as e: @@ -385,7 +453,7 @@ async def main() -> None: detail=last_error, ) await kill_dev_servers(repo_dir) - await fix_with_claude(repo_dir, last_error) + await fix_with_claude(repo_dir, last_error, device_type=device_type) else: emit_log( "implementing", diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index 6c022a9..210e270 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -48,7 +48,7 @@ RUN apt-get update && apt-get install -y \ WORKDIR /workspace # Install Playwright and Chromium (without --with-deps since deps are installed above) -RUN npm install -g playwright@1.49.0 && \ +RUN npm install -g playwright@1.49.0 @playwright/mcp@latest && \ npx playwright install chromium # Install GitHub CLI @@ -70,7 +70,6 @@ COPY docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh COPY docker/worker-analyze.py /usr/local/bin/worker-analyze.py COPY docker/worker-implement.py /usr/local/bin/worker-implement.py COPY docker/worker-createpr.py /usr/local/bin/worker-createpr.py -COPY docker/take-screenshot.mjs /usr/local/bin/take-screenshot.mjs RUN chmod +x /usr/local/bin/worker-entrypoint.sh ENTRYPOINT ["/usr/local/bin/worker-entrypoint.sh"]