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
21 changes: 21 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion backend/app/infra/k8s_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 0 additions & 9 deletions backend/app/infra/s3_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
8 changes: 7 additions & 1 deletion backend/app/usecase/session_usecase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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",
Expand Down
21 changes: 14 additions & 7 deletions backend/app/workflow/session_analyzer_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment on lines +60 to +62

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

S3Client.download_json() calls json.loads() and will raise on invalid JSON; extract_results() doesn’t catch that, so a malformed/partial proposals.json will crash the graph instead of returning a clean failure. Wrap the download/parse in a try/except (e.g., json.JSONDecodeError) and return a failed status with a helpful error.

Copilot uses AI. Check for mistakes.
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,
}


Expand Down
12 changes: 10 additions & 2 deletions backend/app/workflow/session_implementation_graph.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json as _json
import logging
from typing import Any

Expand All @@ -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"],
Expand Down
2 changes: 2 additions & 0 deletions backend/app/workflow/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class SessionAnalyzerState(TypedDict):
# Output
proposals: list[dict] | None
before_screenshot_key: str | None
device_type: str | None


class SessionImplementationState(TypedDict):
Expand All @@ -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
Expand Down
15 changes: 0 additions & 15 deletions docker/take-screenshot.mjs

This file was deleted.

Loading