Skip to content

Commit 74d5db1

Browse files
authored
fix(wework): track project tasks with stable APIs (#2427)
1 parent b930db5 commit 74d5db1

12 files changed

Lines changed: 217 additions & 251 deletions

File tree

backend/app/api/endpoints/deliveries.py

Lines changed: 0 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,6 @@
4545
LoopItemUpdate,
4646
MyWorkItemResponse,
4747
MyWorkListResponse,
48-
RuntimeTaskTrack,
49-
RuntimeTaskTrackingStatusUpdate,
50-
RuntimeTaskTrackResponse,
5148
)
5249
from app.services.cloud_projects import cloud_project_service
5350
from app.services.delivery import delivery_service
@@ -197,109 +194,6 @@ def bind_cloud_project_task(
197194
return LoopItemTaskBindingResponse.model_validate(binding)
198195

199196

200-
def _tracked_item_response(
201-
db: Session,
202-
item_id: str,
203-
current_user: User,
204-
) -> LoopItemResponse:
205-
if external_loop_item_provider.is_external_item(db, item_id):
206-
return LoopItemResponse.model_validate(
207-
external_loop_item_provider.get(db, item_id, current_user.id)
208-
)
209-
item = loop_item_service.get(db, item_id, current_user.id)
210-
return _loop_item_response(db, item, current_user)
211-
212-
213-
@router.post(
214-
"/cloud-projects/{project_id}/tasks/track",
215-
response_model=RuntimeTaskTrackResponse,
216-
status_code=status.HTTP_201_CREATED,
217-
)
218-
def track_cloud_project_task(
219-
project_id: int,
220-
values: RuntimeTaskTrack,
221-
db: Session = Depends(get_db),
222-
current_user: User = Depends(get_current_user),
223-
) -> RuntimeTaskTrackResponse:
224-
existing = loop_item_service.find_active_task_binding(
225-
db, current_user.id, values.device_id, values.task_id
226-
)
227-
if existing is not None and existing.loop_item_id:
228-
if str(existing.cloud_project_id) != str(project_id):
229-
raise HTTPException(
230-
status.HTTP_409_CONFLICT,
231-
"Runtime task is already tracked by another project",
232-
)
233-
return RuntimeTaskTrackResponse(
234-
item=_tracked_item_response(db, existing.loop_item_id, current_user),
235-
binding=LoopItemTaskBindingResponse.model_validate(existing),
236-
)
237-
238-
project = cloud_project_service.get(db, project_id, current_user.id)
239-
created = loop_item_provider_router.create(
240-
db,
241-
project,
242-
current_user,
243-
LoopItemCreate(
244-
title=values.task_title,
245-
description=values.description,
246-
status="in_progress",
247-
),
248-
)
249-
item_id = str(created.values["id"])
250-
external_loop_item_provider.ensure_shadow(db, item_id, current_user.id)
251-
binding = loop_item_service.bind_task(
252-
db, item_id, values.binding(), current_user.id
253-
)
254-
return RuntimeTaskTrackResponse(
255-
item=LoopItemResponse.model_validate(created.values),
256-
binding=LoopItemTaskBindingResponse.model_validate(binding),
257-
)
258-
259-
260-
@router.patch(
261-
"/runtime-tasks/cloud-context/tracking-status",
262-
response_model=LoopItemResponse | None,
263-
)
264-
def update_runtime_task_tracking_status(
265-
values: RuntimeTaskTrackingStatusUpdate,
266-
db: Session = Depends(get_db),
267-
current_user: User = Depends(get_current_user),
268-
) -> LoopItemResponse | None:
269-
binding = loop_item_service.find_active_task_binding(
270-
db, current_user.id, values.device_id, values.task_id
271-
)
272-
if binding is None or not binding.loop_item_id:
273-
return None
274-
item = _tracked_item_response(db, binding.loop_item_id, current_user)
275-
next_status: str | None = None
276-
if values.execution_status == "running" and item.status in {
277-
"inbox",
278-
"pending",
279-
"in_review",
280-
}:
281-
next_status = "in_progress"
282-
elif values.execution_status == "succeeded" and item.status == "in_progress":
283-
next_status = "in_review"
284-
if next_status is None:
285-
return item
286-
if external_loop_item_provider.is_external_item(db, item.id):
287-
updated = external_loop_item_provider.update(
288-
db,
289-
item.id,
290-
current_user.id,
291-
LoopItemUpdate(version=item.version, status=next_status),
292-
)
293-
return LoopItemResponse.model_validate(updated)
294-
stored = loop_item_service.update(
295-
db,
296-
item.id,
297-
current_user.id,
298-
LoopItemUpdate(version=item.version, status=next_status),
299-
)
300-
return _loop_item_response(db, stored, current_user)
301-
302-
303197
@router.delete("/runtime-tasks/cloud-context", status_code=status.HTTP_204_NO_CONTENT)
304198
def unbind_runtime_task_cloud_context(
305199
values: LoopItemTaskBind,

backend/app/schemas/delivery.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -234,39 +234,6 @@ def normalize_unlinked_at(cls, value: object) -> object:
234234
return LoopItemResponse.normalize_unset_datetime(value)
235235

236236

237-
class RuntimeTaskTrack(BaseModel):
238-
model_config = ConfigDict(populate_by_name=True)
239-
240-
device_id: str = Field(alias="deviceId", min_length=1, max_length=100)
241-
task_id: str = Field(alias="taskId", min_length=1, max_length=255)
242-
task_title: str = Field(alias="taskTitle", min_length=1, max_length=255)
243-
description: str = ""
244-
backend_task_id: int | None = Field(default=None, alias="backendTaskId")
245-
246-
def binding(self) -> LoopItemTaskBind:
247-
return LoopItemTaskBind(
248-
deviceId=self.device_id,
249-
taskId=self.task_id,
250-
taskTitle=self.task_title,
251-
backendTaskId=self.backend_task_id,
252-
)
253-
254-
255-
class RuntimeTaskTrackingStatusUpdate(BaseModel):
256-
model_config = ConfigDict(populate_by_name=True)
257-
258-
device_id: str = Field(alias="deviceId", min_length=1, max_length=100)
259-
task_id: str = Field(alias="taskId", min_length=1, max_length=255)
260-
execution_status: Literal["running", "succeeded", "failed", "cancelled"] = Field(
261-
alias="executionStatus"
262-
)
263-
264-
265-
class RuntimeTaskTrackResponse(BaseModel):
266-
item: LoopItemResponse
267-
binding: LoopItemTaskBindingResponse
268-
269-
270237
class CloudTaskContextResponse(LoopItemTaskBindingResponse):
271238
project: CloudProjectResponse
272239
loop_item: LoopItemResponse | None = None

backend/app/services/loop_items/service.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -788,24 +788,6 @@ def find_cloud_context(
788788
item = db.get(LoopItem, binding.loop_item_id) if binding.loop_item_id else None
789789
return binding, project, item
790790

791-
def find_active_task_binding(
792-
self,
793-
db: Session,
794-
user_id: int,
795-
device_id: str,
796-
task_id: str,
797-
) -> LoopItemTaskBinding | None:
798-
return (
799-
db.query(LoopItemTaskBinding)
800-
.filter(
801-
LoopItemTaskBinding.task_user_id == user_id,
802-
LoopItemTaskBinding.device_id == device_id,
803-
LoopItemTaskBinding.task_id == task_id,
804-
loop_datetime_is_unset(LoopItemTaskBinding.unlinked_at),
805-
)
806-
.first()
807-
)
808-
809791
def unbind_cloud_context(
810792
self, db: Session, values: LoopItemTaskBind, user_id: int
811793
) -> None:

backend/tests/api/test_cloud_projects_api.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -785,53 +785,6 @@ def provider_request(
785785
assert all("server-only-secret" not in str(payload) for _, _, payload in requests)
786786

787787

788-
def test_explicit_project_selection_tracks_runtime_task_idempotently(
789-
test_client: TestClient,
790-
test_db: Session,
791-
test_user: User,
792-
test_token: str,
793-
) -> None:
794-
project = test_client.post(
795-
"/api/v1/cloud-projects",
796-
headers=_auth(test_token),
797-
json={"project_key": "track", "name": "Tracked project"},
798-
).json()
799-
payload = {
800-
"deviceId": "desktop-1",
801-
"taskId": "runtime-task-1",
802-
"taskTitle": "Implement explicit task tracking",
803-
"description": "Created from the Wework task composer.",
804-
}
805-
tracked = test_client.post(
806-
f"/api/v1/cloud-projects/{project['id']}/tasks/track",
807-
headers=_auth(test_token),
808-
json=payload,
809-
)
810-
assert tracked.status_code == 201
811-
assert tracked.json()["item"]["status"] == "in_progress"
812-
item_id = tracked.json()["item"]["id"]
813-
814-
retried = test_client.post(
815-
f"/api/v1/cloud-projects/{project['id']}/tasks/track",
816-
headers=_auth(test_token),
817-
json=payload,
818-
)
819-
assert retried.status_code == 201
820-
assert retried.json()["item"]["id"] == item_id
821-
822-
reviewed = test_client.patch(
823-
"/api/v1/runtime-tasks/cloud-context/tracking-status",
824-
headers=_auth(test_token),
825-
json={
826-
"deviceId": "desktop-1",
827-
"taskId": "runtime-task-1",
828-
"executionStatus": "succeeded",
829-
},
830-
)
831-
assert reviewed.status_code == 200
832-
assert reviewed.json()["status"] == "in_review"
833-
834-
835788
def test_todo_lifecycle_and_multiple_local_tasks(
836789
test_client: TestClient,
837790
test_db: Session,

docs/en/wegent/developer-guide/cloud-project-collaboration.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ CloudProject
3333

3434
## Data ownership
3535

36-
| Data | Source of truth |
37-
| --- | --- |
38-
| Cloud projects, members, TODOs, task links, delivery metadata | Backend MySQL |
36+
| Data | Source of truth |
37+
| --------------------------------------------------------------------------------------- | -------------------------------- |
38+
| Cloud projects, members, TODOs, task links, delivery metadata | Backend MySQL |
3939
| Local paths, devices, Git, execution configuration, and default project-space reference | Device-local Codex project state |
40-
| Shared files, Markdown, conversations, and delivery snapshots | MinIO/S3 |
41-
| AI access to cloud data | MCP authorized by the Backend |
40+
| Shared files, Markdown, conversations, and delivery snapshots | MinIO/S3 |
41+
| AI access to cloud data | MCP authorized by the Backend |
4242

4343
Objects are isolated by the cloud project's public ID:
4444

@@ -95,12 +95,12 @@ Completed TODOs may be reopened into `in_progress`. Updates carry a `version` va
9595

9696
Reuse `resource_members` and `share_links` with a new `CloudProject` resource type.
9797

98-
| Role | Read | Edit TODOs/files | Manage members | Archive project |
99-
| --- | --- | --- | --- | --- |
100-
| Reporter | Yes | No | No | No |
101-
| Developer | Yes | Yes | No | No |
102-
| Maintainer | Yes | Yes | Yes | No |
103-
| Owner | Yes | Yes | Yes | Yes |
98+
| Role | Read | Edit TODOs/files | Manage members | Archive project |
99+
| ---------- | ---- | ---------------- | -------------- | --------------- |
100+
| Reporter | Yes | No | No | No |
101+
| Developer | Yes | Yes | No | No |
102+
| Maintainer | Yes | Yes | Yes | No |
103+
| Owner | Yes | Yes | Yes | Yes |
104104

105105
Every TODO, delivery, file, and MCP request resolves the caller's cloud-project role first. Inaccessible resources return 404 to avoid disclosing their existence.
106106

@@ -146,6 +146,8 @@ Delivery services do not own TODO CRUD. LoopItem services do not access MinIO di
146146

147147
Creation and updates use separate endpoints rather than PUT upsert. Shared files support folder creation, upload, rename/move, short-lived access, and recursive deletion. A move copies MinIO objects first, commits metadata, and only then removes the old objects; failed moves clean up newly copied objects.
148148

149+
When Wework adds a new runtime task to a cloud project space, it composes the existing primitives: create a `LoopItem`, then bind the runtime task; when execution status changes, read the task context and update the linked TODO. The Backend intentionally has no aggregate tracking endpoint dedicated to that orchestration. This allows the desktop app and Backend to be released independently while the stable TODO-creation, task-binding, and optimistic-locking APIs preserve the same behavior. The desktop app deduplicates concurrent association requests for the same runtime task and reuses a created TODO after a temporary binding failure to avoid duplicate cards.
150+
149151
The Wework Composer encodes cloud projects, directories, files, TODOs, and deliveries as atomic `cloud://` references. Tasks carrying cloud-project context receive the Delivery MCP, and `resolve_cloud_reference` authorizes and resolves every reference in Backend so neither clients nor AI receive S3 credentials. The TODO board refreshes periodically while visible, while writes continue to use `version` optimistic locking for concurrent collaborators.
150152

151153
## Delivery sequence

docs/zh/wegent/developer-guide/cloud-project-collaboration.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ CloudProject
3333

3434
## 数据归属
3535

36-
| 数据 | 事实来源 |
37-
| --- | --- |
38-
| 云项目、成员、TODO、任务关联、交付元数据 | Backend MySQL |
39-
| 本地路径、设备、Git、执行配置和默认项目空间引用 | 本地 Codex 项目状态 |
40-
| 共享文件、Markdown、聊天记录、交付快照 | MinIO/S3 |
41-
| AI 对云空间的访问 | Backend 鉴权后的 MCP |
36+
| 数据 | 事实来源 |
37+
| ----------------------------------------------- | -------------------- |
38+
| 云项目、成员、TODO、任务关联、交付元数据 | Backend MySQL |
39+
| 本地路径、设备、Git、执行配置和默认项目空间引用 | 本地 Codex 项目状态 |
40+
| 共享文件、Markdown、聊天记录、交付快照 | MinIO/S3 |
41+
| AI 对云空间的访问 | Backend 鉴权后的 MCP |
4242

4343
MinIO 对象使用云项目公开 ID 隔离:
4444

@@ -95,12 +95,12 @@ inbox → pending → in_progress → in_review → completed
9595

9696
复用 `resource_members``share_links`,新增 `CloudProject` 资源类型。
9797

98-
| 角色 | 读取 | 编辑 TODO/文件 | 管理成员 | 归档项目 |
99-
| --- | --- | --- | --- | --- |
100-
| Reporter |||||
101-
| Developer |||||
102-
| Maintainer |||||
103-
| Owner |||||
98+
| 角色 | 读取 | 编辑 TODO/文件 | 管理成员 | 归档项目 |
99+
| ---------- | ---- | -------------- | -------- | -------- |
100+
| Reporter | | | | |
101+
| Developer | | | | |
102+
| Maintainer | | | | |
103+
| Owner | | | | |
104104

105105
所有 TODO、交付、文件和 MCP 请求都必须先解析云项目角色。无权限资源统一返回 404,避免泄露资源是否存在。
106106

@@ -146,6 +146,8 @@ Delivery 服务不负责 TODO CRUD;LoopItem 服务不直接访问 MinIO;MCP
146146

147147
创建与更新使用不同端点,不提供 PUT upsert。共享文件支持创建目录、上传、重命名/移动、短期授权访问和递归删除;移动对象时先复制 MinIO 对象、提交元数据,再删除旧对象,失败时清理新对象。
148148

149+
Wework 把新运行任务加入云项目空间时,使用已有的基础能力组合完成:先创建 `LoopItem`,再绑定运行任务;运行状态变化时先读取任务上下文,再更新对应 TODO。Backend 不提供仅为这条编排流程设计的聚合追踪接口,因此桌面端和 Backend 可以独立发布,同时仍由 TODO 创建、任务绑定和乐观锁更新这三类稳定 API 保证行为一致。桌面端会对同一运行任务的并发关联请求去重;如果绑定临时失败,会复用已创建的 TODO 后重试,避免产生重复卡片。
150+
149151
Wework Composer 把云项目、目录、文件、TODO 和交付编码为 `cloud://` 原子引用。任务携带云项目上下文时注入 Delivery MCP;`resolve_cloud_reference` 在 Backend 再次鉴权并解析引用,客户端和 AI 均不接触 S3 凭证。TODO 看板在窗口可见时周期刷新,写操作仍依赖 `version` 乐观锁处理多人并发。
150152

151153
## 实施顺序

0 commit comments

Comments
 (0)