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
39 changes: 39 additions & 0 deletions backend/app/api/endpoints/subtasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@
SubtaskListResponse,
SubtaskUpdate,
)
from app.schemas.turn_file_changes import (
TurnFileChangesDiffResponse,
TurnFileChangesRevertResponse,
)
from app.services.chat.storage import session_manager
from app.services.subtask import subtask_service
from app.services.turn_file_changes import turn_file_changes_service

router = APIRouter()

Expand Down Expand Up @@ -103,6 +108,40 @@ def get_subtask(
)


@router.get(
"/{subtask_id}/file-changes/diff",
response_model=TurnFileChangesDiffResponse,
)
async def get_turn_file_changes_diff(
subtask_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
) -> TurnFileChangesDiffResponse:
"""Load one assistant turn's validated diff from its execution device."""
return await turn_file_changes_service.get_diff(
db=db,
user_id=current_user.id,
subtask_id=subtask_id,
)


@router.post(
"/{subtask_id}/file-changes/revert",
response_model=TurnFileChangesRevertResponse,
)
async def revert_turn_file_changes(
subtask_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
) -> TurnFileChangesRevertResponse:
"""Reverse one assistant turn without overwriting later workspace changes."""
return await turn_file_changes_service.revert(
db=db,
user_id=current_user.id,
subtask_id=subtask_id,
)


@router.put("/{subtask_id}", response_model=SubtaskInDB)
def update_subtask(
subtask_id: int,
Expand Down
54 changes: 54 additions & 0 deletions backend/app/schemas/turn_file_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""Schemas for per-turn coding file changes."""

from datetime import datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field


class TurnFileChangeItem(BaseModel):
"""One file changed during an agent turn."""

model_config = ConfigDict(extra="forbid")

old_path: str | None = None
path: str = Field(min_length=1)
change_type: Literal["created", "modified", "deleted", "renamed"]
additions: int = Field(ge=0)
deletions: int = Field(ge=0)
binary: bool = False


class TurnFileChangesSummary(BaseModel):
"""Persisted lightweight summary for a turn patch artifact."""

model_config = ConfigDict(extra="forbid")

version: Literal[1]
status: Literal["active", "reverted", "conflicted", "artifact_missing"]
artifact_id: str = Field(min_length=1)
device_id: str = Field(min_length=1)
workspace_path: str = Field(min_length=1)
file_count: int = Field(ge=0)
additions: int = Field(ge=0)
deletions: int = Field(ge=0)
files: list[TurnFileChangeItem]
reverted_at: datetime | None = None


class TurnFileChangesDiffResponse(BaseModel):
"""Validated unified diff returned from the owning device."""

subtask_id: int
diff: str


class TurnFileChangesRevertResponse(BaseModel):
"""Updated summary returned after an idempotent revert."""

subtask_id: int
file_changes: TurnFileChangesSummary
1 change: 1 addition & 0 deletions backend/app/services/chat/trigger/unified.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ async def build_execution_request(
previous_bot_id=previous_bot_id,
web_runtime_guidance=web_runtime_guidance,
)
request.device_id = device_id or request.device_id

# Merge reasoning config from API/model selection into model_config.
# Priority: explicit API reasoning_config > UI model_options > model think_config.
Expand Down
158 changes: 158 additions & 0 deletions backend/app/services/device/command_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,156 @@ def fail(message, code=64):
f"python3 -c {shlex.quote(READ_RUNTIME_AUTH_FILE_SCRIPT)}"
)

TURN_FILE_CHANGES_SCRIPT = """
import gzip
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path

MAX_PATCH_BYTES = 20 * 1024 * 1024
ARTIFACT_PATTERN = re.compile(
r"turn-file-changes/([1-9][0-9]*)/([1-9][0-9]*)"
)


def finish(payload, code=0):
print(json.dumps(payload, ensure_ascii=False))
sys.exit(code)


def fail(message, code=64, status=None):
payload = {"success": False, "error": message}
if status:
payload["status"] = status
finish(payload, code)


if len(sys.argv) != 3:
fail("mode and artifact id are required")

mode = sys.argv[1]
artifact_id = sys.argv[2]
if mode not in {"review", "revert"}:
fail("invalid mode")

match = ARTIFACT_PATTERN.fullmatch(artifact_id)
if not match:
fail("invalid artifact id")

task_id = int(match.group(1))
subtask_id = int(match.group(2))
executor_home = Path(
os.environ.get("WEGENT_EXECUTOR_HOME", "~/.wegent-executor")
).expanduser()
artifact_root = (executor_home / "artifacts").resolve()
artifact_dir = (artifact_root / artifact_id).resolve()
if artifact_root not in artifact_dir.parents:
fail("invalid artifact id")

metadata_path = artifact_dir / "metadata.json"
patch_path = artifact_dir / "changes.patch.gz"
if not metadata_path.is_file() or not patch_path.is_file():
finish(
{
"success": False,
"status": "artifact_missing",
"error": "turn file changes artifact is missing",
}
)

try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail(f"invalid artifact metadata: {exc}", code=65)

if not isinstance(metadata, dict):
fail("invalid artifact metadata", code=65)
if metadata.get("task_id") != task_id or metadata.get("subtask_id") != subtask_id:
fail("artifact metadata id mismatch", code=65)

workspace = Path.cwd().resolve()
try:
metadata_workspace = Path(str(metadata["workspace_path"])).resolve()
except (KeyError, OSError):
fail("invalid artifact workspace", code=65)
if metadata_workspace != workspace:
fail("artifact workspace mismatch", code=65)

try:
with gzip.open(patch_path, "rb") as patch_file:
patch = patch_file.read(MAX_PATCH_BYTES + 1)
except (OSError, gzip.BadGzipFile) as exc:
fail(f"failed to read artifact patch: {exc}", code=65)
if len(patch) > MAX_PATCH_BYTES:
fail("artifact patch exceeds size limit", code=65)
if hashlib.sha256(patch).hexdigest() != metadata.get("checksum"):
fail("artifact patch checksum mismatch", code=65)

if mode == "review":
finish(
{
"success": True,
"diff": patch.decode("utf-8", errors="replace"),
}
)

temp_path = None
try:
with tempfile.NamedTemporaryFile(
prefix="wegent-validated-turn-",
suffix=".patch",
delete=False,
) as temp_file:
temp_file.write(patch)
temp_path = Path(temp_file.name)

check = subprocess.run(
["git", "apply", "--reverse", "--check", "--binary", str(temp_path)],
cwd=workspace,
capture_output=True,
text=True,
)
if check.returncode != 0:
finish(
{
"success": False,
"status": "conflicted",
"error": "patch does not apply",
}
)

apply_result = subprocess.run(
["git", "apply", "--reverse", "--binary", str(temp_path)],
cwd=workspace,
capture_output=True,
text=True,
)
if apply_result.returncode != 0:
finish(
{
"success": False,
"status": "conflicted",
"error": "patch does not apply",
}
)
finish({"success": True, "status": "reverted"})
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
""".strip()

TURN_FILE_CHANGES_REVIEW_COMMAND = (
f"python3 -c {shlex.quote(TURN_FILE_CHANGES_SCRIPT)} review"
)
TURN_FILE_CHANGES_REVERT_COMMAND = (
f"python3 -c {shlex.quote(TURN_FILE_CHANGES_SCRIPT)} revert"
)


DEFAULT_LOCAL_DEVICE_COMMANDS: dict[str, LocalDeviceCommandDefinition] = {
"pwd": LocalDeviceCommandDefinition(command="pwd"),
Expand Down Expand Up @@ -438,6 +588,14 @@ def fail(message, code=64):
command=READ_RUNTIME_AUTH_FILE_COMMAND,
post_processor="json",
),
"turn_file_changes_review": LocalDeviceCommandDefinition(
command=TURN_FILE_CHANGES_REVIEW_COMMAND,
post_processor="json",
),
"turn_file_changes_revert": LocalDeviceCommandDefinition(
command=TURN_FILE_CHANGES_REVERT_COMMAND,
post_processor="json",
),
}


Expand Down
1 change: 1 addition & 0 deletions backend/app/services/execution/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ def extract_completed_result(response_data: dict) -> dict:
"standalone_chat_workspace_path": response_data.get(
"standalone_chat_workspace_path"
),
"file_changes": response_data.get("file_changes"),
"reasoning_content": reasoning_content,
}

Expand Down
Loading
Loading