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
31 changes: 0 additions & 31 deletions backend/app/api/endpoints/cloud_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@
CloudProjectMemberUpdate,
CloudProjectResponse,
CloudProjectUpdate,
LocalBindingCreate,
LocalBindingResponse,
)
from app.services.cloud_files import cloud_file_service
from app.services.cloud_projects import cloud_project_service
Expand Down Expand Up @@ -104,35 +102,6 @@ def archive_cloud_project(
cloud_project_service.archive(db, project_id, current_user.id, version)


@router.post(
"/{project_id}/local-bindings",
response_model=LocalBindingResponse,
status_code=status.HTTP_201_CREATED,
)
def add_local_binding(
project_id: int,
values: LocalBindingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LocalBindingResponse:
binding = cloud_project_service.add_local_binding(
db, project_id, current_user.id, values
)
return LocalBindingResponse.model_validate(binding)


@router.get("/{project_id}/local-bindings", response_model=list[LocalBindingResponse])
def list_local_bindings(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LocalBindingResponse]:
bindings = cloud_project_service.list_local_bindings(
db, project_id, current_user.id
)
return [LocalBindingResponse.model_validate(binding) for binding in bindings]


@router.get("/{project_id}/members", response_model=list[CloudProjectMemberResponse])
def list_cloud_project_members(
project_id: int,
Expand Down
106 changes: 106 additions & 0 deletions backend/app/api/endpoints/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
LoopItemUpdate,
MyWorkItemResponse,
MyWorkListResponse,
RuntimeTaskTrack,
RuntimeTaskTrackingStatusUpdate,
RuntimeTaskTrackResponse,
)
from app.services.cloud_projects import cloud_project_service
from app.services.delivery import delivery_service
Expand Down Expand Up @@ -194,6 +197,109 @@ def bind_cloud_project_task(
return LoopItemTaskBindingResponse.model_validate(binding)


def _tracked_item_response(
db: Session,
item_id: str,
current_user: User,
) -> LoopItemResponse:
if external_loop_item_provider.is_external_item(db, item_id):
return LoopItemResponse.model_validate(
external_loop_item_provider.get(db, item_id, current_user.id)
)
item = loop_item_service.get(db, item_id, current_user.id)
return _loop_item_response(db, item, current_user)


@router.post(
"/cloud-projects/{project_id}/tasks/track",
response_model=RuntimeTaskTrackResponse,
status_code=status.HTTP_201_CREATED,
)
def track_cloud_project_task(
project_id: int,
values: RuntimeTaskTrack,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> RuntimeTaskTrackResponse:
existing = loop_item_service.find_active_task_binding(
db, current_user.id, values.device_id, values.task_id
)
if existing is not None and existing.loop_item_id:
if str(existing.cloud_project_id) != str(project_id):
raise HTTPException(
status.HTTP_409_CONFLICT,
"Runtime task is already tracked by another project",
)
return RuntimeTaskTrackResponse(
item=_tracked_item_response(db, existing.loop_item_id, current_user),
binding=LoopItemTaskBindingResponse.model_validate(existing),
)

project = cloud_project_service.get(db, project_id, current_user.id)
created = loop_item_provider_router.create(
db,
project,
current_user,
LoopItemCreate(
title=values.task_title,
description=values.description,
status="in_progress",
),
Comment on lines +243 to +247

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^backend/app/api/endpoints/deliveries\.py$|board|loop_item|project|delivery)' | head -200

echo "== deliveries outline =="
ast-grep outline backend/app/api/endpoints/deliveries.py --view condensed || true

echo "== relevant deliveries sections =="
sed -n '1,380p' backend/app/api/endpoints/deliveries.py | cat -n

echo "== search lifecycle/status identifiers =="
rg -n 'in_progress|in_review|execution_status|status_ids|status_id|project board|board|LoopItemCreate|LoopItemUpdate|loop_item_service|external_loop_item_provider|configured' backend/app -S

Repository: wecode-ai/Wegent

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== loop_item service outline =="
ast-grep outline backend/app/services/loop_items/service.py --view signatures || true

echo "== loop_item schema relevant lines =="
sed -n '1,80p' backend/app/schemas/delivery.py | cat -n

echo "== loop_item provider router relevant lines =="
sed -n '1,260p' backend/app/services/loop_items/provider_router.py | cat -n

echo "== local loop item service relevant methods =="
rg -n "def (create|update|validate.*status|normalize.*status|response_values)|status_ids|board_config|board_mapping|in_progress|in_review" backend/app/services/loop_items/service.py backend/app/schemas/delivery.py backend/app/schemas/project.py -S

echo "== project schema relevant lines =="
sed -n '90,155p' backend/app/schemas/cloud_project.py | cat -n

echo "== provider router status tests/usages =="
rg -n "board_mapping|board_config|status_ids|required_statuses|LoopItemCreate|LoopItemUpdate|resolve.*status|to_board|from_board|in_progress|in_review" backend/app -S --max-count 120

Repository: wecode-ai/Wegent

Length of output: 20222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== loop_item service create/update implementations =="
sed -n '45,300p' backend/app/services/loop_items/service.py | cat -n
sed -n '520,570p' backend/app/services/loop_items/service.py | cat -n

echo "== project update implementation =="
sed -n '130,205p' backend/app/services/cloud_projects/service.py | cat -n

echo "== runtime task tracking status schema =="
sed -n '240,280p' backend/app/schemas/delivery.py | cat -n

echo "== project status metadata default =="
sed -n '65,90p' backend/app/services/cloud_projects/service.py | cat -n

echo "== status map tests relevant =="
rg -n "board_mapping|board_config|in_progress|in_review|RuntimeTaskTrack|tracking-status|task_provider" backend/tests -S

Repository: wecode-ai/Wegent

Length of output: 26392


Map runtime status transitions to the configured board.

Native projects validate LoopItemCreate and LoopItemUpdate statuses against board_config.statuses. A custom native board can remove or rename in_progress/in_review, so track_cloud_project_task() and update_runtime_task_tracking_status() will reject those transitions unless the runtime mapping uses the configured status IDs. Store or look up those IDs when tracking and apply the same mapping to both transitions.

📍 Affects 1 file
  • backend/app/api/endpoints/deliveries.py#L243-L247 (this comment)
  • backend/app/api/endpoints/deliveries.py#L275-L300
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/endpoints/deliveries.py` around lines 243 - 247, The runtime
tracking flow currently hardcodes in_progress and in_review, which may not exist
in a custom native board. Update track_cloud_project_task() and
update_runtime_task_tracking_status() to resolve and reuse the corresponding
configured status IDs from board_config.statuses for both LoopItemCreate and
LoopItemUpdate transitions, preserving the existing transition behavior while
validating against the configured board.

)
item_id = str(created.values["id"])
external_loop_item_provider.ensure_shadow(db, item_id, current_user.id)
binding = loop_item_service.bind_task(
db, item_id, values.binding(), current_user.id
)
return RuntimeTaskTrackResponse(
item=LoopItemResponse.model_validate(created.values),
binding=LoopItemTaskBindingResponse.model_validate(binding),
)


@router.patch(
"/runtime-tasks/cloud-context/tracking-status",
response_model=LoopItemResponse | None,
)
def update_runtime_task_tracking_status(
values: RuntimeTaskTrackingStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LoopItemResponse | None:
binding = loop_item_service.find_active_task_binding(
db, current_user.id, values.device_id, values.task_id
)
if binding is None or not binding.loop_item_id:
return None
item = _tracked_item_response(db, binding.loop_item_id, current_user)
next_status: str | None = None
if values.execution_status == "running" and item.status in {
"inbox",
"pending",
"in_review",
}:
next_status = "in_progress"
elif values.execution_status == "succeeded" and item.status == "in_progress":
next_status = "in_review"
if next_status is None:
return item
if external_loop_item_provider.is_external_item(db, item.id):
updated = external_loop_item_provider.update(
db,
item.id,
current_user.id,
LoopItemUpdate(version=item.version, status=next_status),
)
return LoopItemResponse.model_validate(updated)
stored = loop_item_service.update(
db,
item.id,
current_user.id,
LoopItemUpdate(version=item.version, status=next_status),
)
return _loop_item_response(db, stored, current_user)


@router.delete("/runtime-tasks/cloud-context", status_code=status.HTTP_204_NO_CONTENT)
def unbind_runtime_task_cloud_context(
values: LoopItemTaskBind,
Expand Down
2 changes: 0 additions & 2 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from app.models.cloud_project import (
CloudProject,
CloudProjectFile,
CloudProjectLocalBinding,
LoopItemTaskBinding,
)
from app.models.delivery import (
Expand Down Expand Up @@ -67,7 +66,6 @@
"DingtalkSyncedNode",
"CloudProject",
"CloudProjectFile",
"CloudProjectLocalBinding",
"LoopItemTaskBinding",
"LoopItem",
"LoopItemAttachment",
Expand Down
2 changes: 0 additions & 2 deletions backend/app/models/cloud_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@
from app.models.delivery import (
CloudProject,
CloudProjectFile,
CloudProjectLocalBinding,
LoopItemTaskBinding,
)

__all__ = [
"CloudProject",
"CloudProjectFile",
"CloudProjectLocalBinding",
"LoopItemTaskBinding",
]
8 changes: 0 additions & 8 deletions backend/app/models/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,6 @@ def tags(self) -> list[str]:
return [str(tag) for tag in tags]


class CloudProjectLocalBinding(LoopNode):
__mapper_args__ = {"polymorphic_identity": "local_binding"}

def __init__(self, **kwargs: object) -> None:
kwargs.setdefault("is_default", False)
super().__init__(**kwargs)


class LoopItemTaskBinding(LoopNode):
__mapper_args__ = {"polymorphic_identity": "execution"}

Expand Down
24 changes: 0 additions & 24 deletions backend/app/schemas/cloud_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,30 +222,6 @@ class CloudProjectListResponse(BaseModel):
items: list[CloudProjectResponse]


class LocalBindingCreate(BaseModel):
local_project_id: int
device_id: str | None = Field(default=None, max_length=100)
is_default: bool = False


class LocalBindingResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: SnowflakeId
cloud_project_id: SnowflakeId
local_project_id: int
user_id: int
device_id: str | None
is_default: bool
created_at: datetime
updated_at: datetime

@field_validator("device_id", mode="before")
@classmethod
def normalize_empty_device_id(cls, value: object) -> object:
return None if value == "" else value


class CloudProjectMemberCreate(BaseModel):
user_id: int = Field(ge=1)
role: BaseRole = BaseRole.Developer
Expand Down
33 changes: 33 additions & 0 deletions backend/app/schemas/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,39 @@ def normalize_unlinked_at(cls, value: object) -> object:
return LoopItemResponse.normalize_unset_datetime(value)


class RuntimeTaskTrack(BaseModel):
model_config = ConfigDict(populate_by_name=True)

device_id: str = Field(alias="deviceId", min_length=1, max_length=100)
task_id: str = Field(alias="taskId", min_length=1, max_length=255)
task_title: str = Field(alias="taskTitle", min_length=1, max_length=255)
description: str = ""
backend_task_id: int | None = Field(default=None, alias="backendTaskId")

def binding(self) -> LoopItemTaskBind:
return LoopItemTaskBind(
deviceId=self.device_id,
taskId=self.task_id,
taskTitle=self.task_title,
backendTaskId=self.backend_task_id,
)


class RuntimeTaskTrackingStatusUpdate(BaseModel):
model_config = ConfigDict(populate_by_name=True)

device_id: str = Field(alias="deviceId", min_length=1, max_length=100)
task_id: str = Field(alias="taskId", min_length=1, max_length=255)
execution_status: Literal["running", "succeeded", "failed", "cancelled"] = Field(
alias="executionStatus"
)


class RuntimeTaskTrackResponse(BaseModel):
item: LoopItemResponse
binding: LoopItemTaskBindingResponse


class CloudTaskContextResponse(LoopItemTaskBindingResponse):
project: CloudProjectResponse
loop_item: LoopItemResponse | None = None
Expand Down
62 changes: 1 addition & 61 deletions backend/app/services/cloud_projects/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@
from sqlalchemy.orm import Session

from app.core.provider_credentials import store_provider_config
from app.models.cloud_project import CloudProject, CloudProjectLocalBinding
from app.models.cloud_project import CloudProject
from app.models.delivery import LoopItem, loop_datetime_is_unset
from app.models.project import Project
from app.models.resource_member import MemberStatus, ResourceMember
from app.models.share_link import ResourceType
from app.models.user import User
Expand All @@ -26,7 +25,6 @@
CloudProjectMemberCreate,
CloudProjectMemberUpdate,
CloudProjectUpdate,
LocalBindingCreate,
default_board_statuses,
normalize_provider_config,
)
Expand Down Expand Up @@ -270,64 +268,6 @@ def archive(self, db: Session, project_id: int, user_id: int, version: int) -> N
raise HTTPException(status.HTTP_409_CONFLICT, "Cloud project changed")
db.commit()

def add_local_binding(
self,
db: Session,
cloud_project_id: int,
user_id: int,
values: LocalBindingCreate,
) -> CloudProjectLocalBinding:
require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer)
local_project = (
db.query(Project)
.filter(
Project.id == values.local_project_id,
Project.user_id == user_id,
Project.is_active.is_(True),
)
.first()
)
if local_project is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Local project not found")
if values.is_default:
db.query(CloudProjectLocalBinding).filter(
CloudProjectLocalBinding.cloud_project_id == cloud_project_id,
CloudProjectLocalBinding.user_id == user_id,
CloudProjectLocalBinding.device_id == values.device_id,
).update({"is_default": False})
binding = CloudProjectLocalBinding(
cloud_project_id=cloud_project_id,
user_id=user_id,
**values.model_dump(),
)
db.add(binding)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Local project is already linked"
) from exc
db.refresh(binding)
return binding

def list_local_bindings(
self, db: Session, cloud_project_id: int, user_id: int
) -> list[CloudProjectLocalBinding]:
require_cloud_project_role(db, cloud_project_id, user_id)
return (
db.query(CloudProjectLocalBinding)
.filter(
CloudProjectLocalBinding.cloud_project_id == cloud_project_id,
CloudProjectLocalBinding.user_id == user_id,
)
.order_by(
CloudProjectLocalBinding.is_default.desc(),
CloudProjectLocalBinding.updated_at.desc(),
)
.all()
)

def list_members(
self, db: Session, cloud_project_id: int, user_id: int
) -> list[dict[str, object]]:
Expand Down
18 changes: 18 additions & 0 deletions backend/app/services/loop_items/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,24 @@ def find_cloud_context(
item = db.get(LoopItem, binding.loop_item_id) if binding.loop_item_id else None
return binding, project, item

def find_active_task_binding(
self,
db: Session,
user_id: int,
device_id: str,
task_id: str,
) -> LoopItemTaskBinding | None:
return (
db.query(LoopItemTaskBinding)
.filter(
LoopItemTaskBinding.task_user_id == user_id,
LoopItemTaskBinding.device_id == device_id,
LoopItemTaskBinding.task_id == task_id,
loop_datetime_is_unset(LoopItemTaskBinding.unlinked_at),
)
.first()
Comment on lines +798 to +806

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not treat a deleted TODO binding as active.

LoopItemService.delete soft-deletes the item but does not unlink its task binding. This query then returns that binding because it only checks unlinked_at. The tracking route loads the deleted item and returns 404, so the runtime task cannot be tracked again.

Unlink bindings for deleted items, or exclude them atomically from this lookup. Add a regression test that deletes a tracked item and starts the same runtime task again.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/loop_items/service.py` around lines 795 - 803, The
task-binding lookup must exclude bindings whose associated loop item has been
soft-deleted, rather than relying only on unlinked_at. Update the query around
LoopItemTaskBinding to atomically filter out deleted items, or ensure
LoopItemService.delete unlinks their bindings, while preserving active unlinked
bindings. Add a regression test covering deletion of a tracked item followed by
starting the same runtime task again.

)

def unbind_cloud_context(
self, db: Session, values: LoopItemTaskBind, user_id: int
) -> None:
Expand Down
Loading
Loading