Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/infra/k8s_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ async def stream_pod_logs(
break
# Pod exists but not yet running
if phase == "Pending" and not sandbox_emitted:
yield '@@LOG@@{"phase":"sandbox","message":"Creating sandbox"}'
yield '@@LOG@@{"phase":"sandbox","message":"Creating: sandbox"}'
sandbox_emitted = True
except ApiException as e:
logger.warning("Error listing pods for job %s: %s", job_name, e)
Expand Down
11 changes: 2 additions & 9 deletions backend/app/infra/log_stream_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,8 @@ async def _stream_single_job(job_info: JobInfo) -> None:
}
await queue.put(event)
else:
# Non-structured log line - emit as raw
await queue.put(
{
"job_type": job_info.job_type,
"proposal_index": job_info.proposal_index,
"phase": "running",
"message": line,
}
)
# Non-structured log lines (subprocess stdout, etc.) are internal — skip
pass
except asyncio.CancelledError:
return
except Exception as e:
Expand Down
3 changes: 3 additions & 0 deletions backend/app/usecase/session_usecase.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ async def _run_session_analysis(
plan_json=str(proposal.plan),
selected_proposal_index=selected_proposal_index,
device_type=device_type,
instruction=instruction,
)
)
else:
Expand Down Expand Up @@ -400,6 +401,7 @@ async def _run_session_implementation(
plan_json: str,
selected_proposal_index: int | None,
device_type: str = "desktop",
instruction: str = "",
) -> None:
"""Background: run one session-based implementation workflow."""
db = SessionLocal()
Expand All @@ -424,6 +426,7 @@ async def _run_session_implementation(
"proposal_index": proposal_index,
"proposal_plan": plan_json,
"device_type": device_type,
"instruction": instruction,
"selected_proposal_index": selected_proposal_index,
"k8s_job_name": None,
"status": "pending",
Expand Down
1 change: 1 addition & 0 deletions backend/app/workflow/session_implementation_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async def create_k8s_job(state: SessionImplementationState) -> dict:
{
"plan": state["proposal_plan"],
"device_type": state.get("device_type", "desktop"),
"instruction": state.get("instruction", ""),
},
ensure_ascii=False,
)
Expand Down
1 change: 1 addition & 0 deletions backend/app/workflow/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class SessionImplementationState(TypedDict):
proposal_index: int
proposal_plan: str
device_type: str
instruction: str

# Patch context (for iter > 0)
selected_proposal_index: int | None
Expand Down
133 changes: 99 additions & 34 deletions docker/worker-analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,38 @@

import boto3
from botocore.config import Config
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, query, AssistantMessage, TextBlock, ToolUseBlock
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@0.0.68", "--headless", "--browser", "chromium", "--viewport-size", "1280x800"],
"args": [
"@playwright/mcp@0.0.68",
"--headless",
"--browser",
"chromium",
"--viewport-size",
"1280x800",
],
},
"playwright_mobile": {
"command": "npx",
"args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--device", "iPhone 15"],
"args": [
"@playwright/mcp@0.0.68",
"--headless",
"--browser",
"chromium",
"--device",
"iPhone 15",
],
},
}

Expand Down Expand Up @@ -62,11 +83,17 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None:
return

if tool_name == "Read":
emit_log(phase, f"Reading: {params.get('file_path', '?')}")
path = params.get("file_path", "?").replace("/workspace/repo/", "")
emit_log(phase, f"Reading: {path}")
elif tool_name in ("Write", "Edit"):
emit_log(phase, f"Editing: {params.get('file_path', '?')}")
path = params.get("file_path", "?").replace("/workspace/repo/", "")
emit_log(phase, f"Editing: {path}")
elif tool_name == "Bash":
cmd = params.get("command", "?")
cmd = (
params.get("command", "?")
.replace("/workspace/repo/", "")
.replace("/workspace/repo", ".")
)
emit_log(phase, f"Running: {cmd[:100]}")


Expand All @@ -93,17 +120,24 @@ def s3_download(s3, bucket, key, local_path):
return False


def s3_upload_file(s3, bucket, key, local_path, content_type="application/octet-stream"):
def s3_upload_file(
s3, bucket, key, local_path, content_type="application/octet-stream"
):
for attempt in range(3):
try:
s3.upload_file(
local_path, bucket, key,
local_path,
bucket,
key,
ExtraArgs={"ContentType": content_type},
)
print(f"Uploaded {key}")
return
except Exception as e:
print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr)
print(
f"S3 upload attempt {attempt + 1} failed for {key}: {e}",
file=sys.stderr,
)
if attempt == 2:
raise

Expand All @@ -112,13 +146,18 @@ def s3_upload_text(s3, bucket, key, text, content_type="text/plain"):
for attempt in range(3):
try:
s3.put_object(
Bucket=bucket, Key=key,
Body=text.encode("utf-8"), ContentType=content_type,
Bucket=bucket,
Key=key,
Body=text.encode("utf-8"),
ContentType=content_type,
)
print(f"Uploaded {key}")
return
except Exception as e:
print(f"S3 upload attempt {attempt + 1} failed for {key}: {e}", file=sys.stderr)
print(
f"S3 upload attempt {attempt + 1} failed for {key}: {e}",
file=sys.stderr,
)
if attempt == 2:
raise

Expand Down Expand Up @@ -154,12 +193,17 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None:
text = block.text.strip()
if text.startswith("Browser") or text.startswith("Searching"):
continue
emit_log(phase, text[:200], detail=block.text)
emit_log(phase, f"Thinking: {text[:200]}", detail=block.text)
elif isinstance(block, ToolUseBlock):
_emit_tool_detail(phase, block.name, json.dumps(block.input))


async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_type: str = "desktop") -> None:
async def launch_and_screenshot(
repo_dir: str,
screenshot_output: str,
device_type: str = "desktop",
instruction: str = "",
) -> None:
"""Launch dev server and take screenshot in a single session.

Uses ClaudeSDKClient to keep the same conversation context across
Expand All @@ -176,8 +220,8 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty

async with ClaudeSDKClient(options=options) as client:
# Phase 1: install deps + start dev server
emit_log("launching", "Launching project")
await client.query(f"""You need to launch the web application at {repo_dir}.
emit_log("launching", "Launching: project")
await client.query("""You need to launch the web application.

Follow these steps:
1. Investigate how to start the dev server (check package.json scripts, README, etc.)
Expand All @@ -193,21 +237,37 @@ async def launch_and_screenshot(repo_dir: str, screenshot_output: str, device_ty
await _process_messages(client, "launching")

# Phase 2: take screenshot (same session, so dev server URL is remembered)
emit_log("screenshot", "Taking before screenshot")
emit_log("screenshot", "Taking: before screenshot")
device = "playwright_mobile" if device_type == "mobile" else "playwright"

instruction_block = ""
if instruction:
instruction_block = f"""
## User's change request
\"\"\"{instruction}\"\"\"

Based on this request, you MUST determine which page and area to screenshot:
1. Read the project's routing configuration (e.g. React Router, Next.js pages, Vue Router) to find the relevant page/route
2. Navigate to the page that is most relevant to the user's request (NOT necessarily the root `/`)
3. If the change target is below the fold (e.g. footer, bottom section), use mcp__{device}__browser_evaluate to scroll the element into view before taking the screenshot
"""

await client.query(f"""Now take a screenshot of the running application.

You already know the dev server URL from the previous step.

Use the **{device}** browser tools (mcp__{device}__*) to take the screenshot.

{instruction_block}
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}
1. Read the project's routing configuration to identify the correct page for the user's request
2. Use mcp__{device}__browser_navigate to open the appropriate page URL
3. Use mcp__{device}__browser_wait_for to wait for the page to fully load
4. If the target area is not visible in the viewport, use mcp__{device}__browser_evaluate to scroll it into view
5. 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.
- Navigate to the page most relevant to the user's request, not just the root URL.
- If a specific element needs to be visible, use mcp__{device}__browser_evaluate to scrollIntoView first.
""")
await _process_messages(client, "screenshot")
Expand Down Expand Up @@ -269,7 +329,7 @@ def _extract_proposals_json(collected_text: list[str]) -> dict:
pass

# Strategy 3: Extract from markdown code blocks in full text
for pattern in [r'```json\s*(.*?)```', r'```\s*(.*?)```']:
for pattern in [r"```json\s*(.*?)```", r"```\s*(.*?)```"]:
m = re.search(pattern, full_text, re.DOTALL)
if m:
try:
Expand Down Expand Up @@ -373,7 +433,7 @@ async def generate_proposals(
text = block.text.strip()
if text.startswith("Browser") or text.startswith("Searching"):
continue
emit_log("analyzing", text[:200], detail=block.text)
emit_log("analyzing", f"Thinking: {text[:200]}", detail=block.text)
collected_text.append(block.text)
elif isinstance(block, ToolUseBlock):
_emit_tool_detail("analyzing", block.name, json.dumps(block.input))
Expand All @@ -400,7 +460,7 @@ async def main() -> None:
s3_prefix = f"sessions/{session_id}/iterations/{iteration_index}"

# Step 1: Clone repository
emit_log("cloning", "Cloning repository")
emit_log("cloning", "Cloning: repository")
subprocess.run(
["git", "clone", "--depth", "1", "--branch", branch, repo_url, repo_dir],
check=True,
Expand All @@ -414,9 +474,9 @@ async def main() -> None:
f"/proposals/{selected_proposal_index}/changes.diff"
)
local_patch = f"{tmp_dir}/parent.diff"
emit_log("patching", f"Downloading patch from S3: {patch_key}")
emit_log("patching", "Downloading: patch from S3")
if s3_download(s3, bucket, patch_key, local_patch):
emit_log("patching", "Applying cumulative patch")
emit_log("patching", "Applying: cumulative patch")
result = subprocess.run(
["git", "am", "--3way", local_patch],
cwd=repo_dir,
Expand All @@ -440,15 +500,15 @@ async def main() -> None:
cwd=repo_dir,
check=True,
)
emit_log("patching", "Cumulative patch applied successfully")
emit_log("patching", "Thinking: cumulative patch applied successfully")

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Thinking:" prefix in emit_log("patching", "Thinking: cumulative patch applied successfully") is incorrect — this is a worker-side status message confirming a completed git operation, not Claude's reasoning. The "Thinking:" prefix was introduced to label Claude's TextBlock reasoning output, but it should not be applied here.

Suggested change
emit_log("patching", "Thinking: cumulative patch applied successfully")
emit_log("patching", "Cumulative patch applied successfully")

Copilot uses AI. Check for mistakes.
else:
print(
f"WARNING: Patch not found at s3://{bucket}/{patch_key}",
file=sys.stderr,
)

# Step 2: Code analysis + device detection + proposal generation (no browser needed)
emit_log("analyzing", "Generating design proposals")
emit_log("analyzing", "Thinking: Generating design proposals")
try:
result = await generate_proposals(repo_dir, instruction, num_proposals)
except Exception as e:
Expand All @@ -461,19 +521,23 @@ async def main() -> None:
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})")
emit_log(
"analyzing",
f"Thinking: Generated {len(proposals)} proposals (device: {device_type})",
Comment on lines 511 to +526

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two worker-side status messages — "Thinking: Generating design proposals" and "Thinking: Generated {len(proposals)} proposals (device: {device_type})" — are incorrectly prefixed with "Thinking:". These are plain worker progress messages, not Claude's reasoning output. The "Thinking:" prefix is intended to label Claude's TextBlock content from AssistantMessage. Using it here will mislead users into thinking the AI is actively reasoning when it is not.

Copilot uses AI. Check for mistakes.
)

# 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)
await launch_and_screenshot(
repo_dir, before_path, device_type=device_type, instruction=instruction
)

# Step 4: Upload results to S3
emit_log("uploading", "Uploading results to S3")

# Upload before screenshot
if Path(before_path).exists():
s3_upload_file(
s3, bucket,
s3,
bucket,
f"{s3_prefix}/before.png",
before_path,
content_type="image/png",
Expand All @@ -482,7 +546,8 @@ async def main() -> None:
# Upload proposals.json (includes device_type)
proposals_data = {"device_type": device_type, "proposals": proposals}
s3_upload_text(
s3, bucket,
s3,
bucket,
f"{s3_prefix}/proposals.json",
json.dumps(proposals_data, ensure_ascii=False, indent=2),
content_type="application/json",
Expand Down
2 changes: 1 addition & 1 deletion docker/worker-createpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ async def push_and_create_pr(
"""Use Claude Agent SDK to push the branch and create a PR."""
prompt = f"""You are creating a GitHub Pull Request for UI design changes.

The repository is at {repo_dir}. You are on branch "{branch_name}".
You are on branch "{branch_name}".
The base branch is "{base_branch}".

Here is a summary of the changes that were applied (from the diff):
Expand Down
5 changes: 0 additions & 5 deletions docker/worker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
#!/bin/bash
set -e

echo "=== Worker Starting ==="
echo "Mode: ${WORKER_MODE}"
echo "Session: ${SESSION_ID} Iter: ${ITERATION_INDEX}"
echo "Repository: ${REPO_URL}"

if [ -z "$WORKER_MODE" ]; then
echo "Error: WORKER_MODE is required (analyze, implement, or createpr)"
exit 1
Expand Down
Loading