diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index 0fdd54ff4b..a2965aaa65 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -45,9 +45,6 @@ LoopItemUpdate, MyWorkItemResponse, MyWorkListResponse, - RuntimeTaskTrack, - RuntimeTaskTrackingStatusUpdate, - RuntimeTaskTrackResponse, ) from app.services.cloud_projects import cloud_project_service from app.services.delivery import delivery_service @@ -197,109 +194,6 @@ 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", - ), - ) - 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, diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py index 2888ff82e7..dd166eeb1b 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -234,39 +234,6 @@ 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 diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index d70a41d380..8f4ec1cf48 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -788,24 +788,6 @@ 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() - ) - def unbind_cloud_context( self, db: Session, values: LoopItemTaskBind, user_id: int ) -> None: diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 05eec7f0be..c784386a80 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -785,53 +785,6 @@ def provider_request( assert all("server-only-secret" not in str(payload) for _, _, payload in requests) -def test_explicit_project_selection_tracks_runtime_task_idempotently( - test_client: TestClient, - test_db: Session, - test_user: User, - test_token: str, -) -> None: - project = test_client.post( - "/api/v1/cloud-projects", - headers=_auth(test_token), - json={"project_key": "track", "name": "Tracked project"}, - ).json() - payload = { - "deviceId": "desktop-1", - "taskId": "runtime-task-1", - "taskTitle": "Implement explicit task tracking", - "description": "Created from the Wework task composer.", - } - tracked = test_client.post( - f"/api/v1/cloud-projects/{project['id']}/tasks/track", - headers=_auth(test_token), - json=payload, - ) - assert tracked.status_code == 201 - assert tracked.json()["item"]["status"] == "in_progress" - item_id = tracked.json()["item"]["id"] - - retried = test_client.post( - f"/api/v1/cloud-projects/{project['id']}/tasks/track", - headers=_auth(test_token), - json=payload, - ) - assert retried.status_code == 201 - assert retried.json()["item"]["id"] == item_id - - reviewed = test_client.patch( - "/api/v1/runtime-tasks/cloud-context/tracking-status", - headers=_auth(test_token), - json={ - "deviceId": "desktop-1", - "taskId": "runtime-task-1", - "executionStatus": "succeeded", - }, - ) - assert reviewed.status_code == 200 - assert reviewed.json()["status"] == "in_review" - - def test_todo_lifecycle_and_multiple_local_tasks( test_client: TestClient, test_db: Session, diff --git a/docs/en/wegent/developer-guide/cloud-project-collaboration.md b/docs/en/wegent/developer-guide/cloud-project-collaboration.md index cc208dcaef..d3f85c15cb 100644 --- a/docs/en/wegent/developer-guide/cloud-project-collaboration.md +++ b/docs/en/wegent/developer-guide/cloud-project-collaboration.md @@ -33,12 +33,12 @@ CloudProject ## Data ownership -| Data | Source of truth | -| --- | --- | -| Cloud projects, members, TODOs, task links, delivery metadata | Backend MySQL | +| Data | Source of truth | +| --------------------------------------------------------------------------------------- | -------------------------------- | +| Cloud projects, members, TODOs, task links, delivery metadata | Backend MySQL | | Local paths, devices, Git, execution configuration, and default project-space reference | Device-local Codex project state | -| Shared files, Markdown, conversations, and delivery snapshots | MinIO/S3 | -| AI access to cloud data | MCP authorized by the Backend | +| Shared files, Markdown, conversations, and delivery snapshots | MinIO/S3 | +| AI access to cloud data | MCP authorized by the Backend | Objects are isolated by the cloud project's public ID: @@ -95,12 +95,12 @@ Completed TODOs may be reopened into `in_progress`. Updates carry a `version` va Reuse `resource_members` and `share_links` with a new `CloudProject` resource type. -| Role | Read | Edit TODOs/files | Manage members | Archive project | -| --- | --- | --- | --- | --- | -| Reporter | Yes | No | No | No | -| Developer | Yes | Yes | No | No | -| Maintainer | Yes | Yes | Yes | No | -| Owner | Yes | Yes | Yes | Yes | +| Role | Read | Edit TODOs/files | Manage members | Archive project | +| ---------- | ---- | ---------------- | -------------- | --------------- | +| Reporter | Yes | No | No | No | +| Developer | Yes | Yes | No | No | +| Maintainer | Yes | Yes | Yes | No | +| Owner | Yes | Yes | Yes | Yes | Every TODO, delivery, file, and MCP request resolves the caller's cloud-project role first. Inaccessible resources return 404 to avoid disclosing their existence. @@ -146,6 +146,8 @@ Delivery services do not own TODO CRUD. LoopItem services do not access MinIO di 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. +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. + 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. ## Delivery sequence diff --git a/docs/zh/wegent/developer-guide/cloud-project-collaboration.md b/docs/zh/wegent/developer-guide/cloud-project-collaboration.md index ed7676406f..b003bf13e7 100644 --- a/docs/zh/wegent/developer-guide/cloud-project-collaboration.md +++ b/docs/zh/wegent/developer-guide/cloud-project-collaboration.md @@ -33,12 +33,12 @@ CloudProject ## 数据归属 -| 数据 | 事实来源 | -| --- | --- | -| 云项目、成员、TODO、任务关联、交付元数据 | Backend MySQL | -| 本地路径、设备、Git、执行配置和默认项目空间引用 | 本地 Codex 项目状态 | -| 共享文件、Markdown、聊天记录、交付快照 | MinIO/S3 | -| AI 对云空间的访问 | Backend 鉴权后的 MCP | +| 数据 | 事实来源 | +| ----------------------------------------------- | -------------------- | +| 云项目、成员、TODO、任务关联、交付元数据 | Backend MySQL | +| 本地路径、设备、Git、执行配置和默认项目空间引用 | 本地 Codex 项目状态 | +| 共享文件、Markdown、聊天记录、交付快照 | MinIO/S3 | +| AI 对云空间的访问 | Backend 鉴权后的 MCP | MinIO 对象使用云项目公开 ID 隔离: @@ -95,12 +95,12 @@ inbox → pending → in_progress → in_review → completed 复用 `resource_members` 和 `share_links`,新增 `CloudProject` 资源类型。 -| 角色 | 读取 | 编辑 TODO/文件 | 管理成员 | 归档项目 | -| --- | --- | --- | --- | --- | -| Reporter | 是 | 否 | 否 | 否 | -| Developer | 是 | 是 | 否 | 否 | -| Maintainer | 是 | 是 | 是 | 否 | -| Owner | 是 | 是 | 是 | 是 | +| 角色 | 读取 | 编辑 TODO/文件 | 管理成员 | 归档项目 | +| ---------- | ---- | -------------- | -------- | -------- | +| Reporter | 是 | 否 | 否 | 否 | +| Developer | 是 | 是 | 否 | 否 | +| Maintainer | 是 | 是 | 是 | 否 | +| Owner | 是 | 是 | 是 | 是 | 所有 TODO、交付、文件和 MCP 请求都必须先解析云项目角色。无权限资源统一返回 404,避免泄露资源是否存在。 @@ -146,6 +146,8 @@ Delivery 服务不负责 TODO CRUD;LoopItem 服务不直接访问 MinIO;MCP 创建与更新使用不同端点,不提供 PUT upsert。共享文件支持创建目录、上传、重命名/移动、短期授权访问和递归删除;移动对象时先复制 MinIO 对象、提交元数据,再删除旧对象,失败时清理新对象。 +Wework 把新运行任务加入云项目空间时,使用已有的基础能力组合完成:先创建 `LoopItem`,再绑定运行任务;运行状态变化时先读取任务上下文,再更新对应 TODO。Backend 不提供仅为这条编排流程设计的聚合追踪接口,因此桌面端和 Backend 可以独立发布,同时仍由 TODO 创建、任务绑定和乐观锁更新这三类稳定 API 保证行为一致。桌面端会对同一运行任务的并发关联请求去重;如果绑定临时失败,会复用已创建的 TODO 后重试,避免产生重复卡片。 + Wework Composer 把云项目、目录、文件、TODO 和交付编码为 `cloud://` 原子引用。任务携带云项目上下文时注入 Delivery MCP;`resolve_cloud_reference` 在 Backend 再次鉴权并解析引用,客户端和 AI 均不接触 S3 凭证。TODO 看板在窗口可见时周期刷新,写操作仍依赖 `version` 乐观锁处理多人并发。 ## 实施顺序 diff --git a/wework/src/api/deliveries.test.ts b/wework/src/api/deliveries.test.ts new file mode 100644 index 0000000000..ed7addf95c --- /dev/null +++ b/wework/src/api/deliveries.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test, vi } from 'vitest' +import { createDeliveryApi, type CloudLoopItem, type CloudTaskContext } from './deliveries' +import { ApiError, type HttpClient } from './http' + +const trackedItem: CloudLoopItem = { + id: 'WEG-1', + cloud_project_id: 'project-1', + sequence_number: 1, + parent_id: null, + created_by_user_id: 1, + assignee_user_id: null, + title: 'Runtime task', + description: 'Track this task', + status: 'in_progress', + priority: 'none', + due_at: null, + tags: [], + sort_order: 1, + current_delivery_id: null, + version: 1, + created_at: '2026-08-05T00:00:00Z', + updated_at: '2026-08-05T00:00:00Z', + completed_at: null, +} + +function clientWith(methods: Partial): HttpClient { + return methods as HttpClient +} + +describe('createDeliveryApi task tracking', () => { + test('creates and binds a board item through stable project APIs', async () => { + const get = vi.fn().mockRejectedValue(new ApiError('Cloud context not found', 404)) + const post = vi.fn().mockResolvedValueOnce(trackedItem).mockResolvedValueOnce(undefined) + const api = createDeliveryApi(clientWith({ get, post })) + const task = { deviceId: 'local-device', taskId: 'runtime-1' } + + const first = api.trackProjectTask('project-1', task, 'Runtime task', 'Track this task') + const second = api.trackProjectTask( + 'project-1', + task, + 'Changed during rendering', + 'Changed during rendering' + ) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { item: trackedItem }, + { item: trackedItem }, + ]) + expect(get).toHaveBeenCalledOnce() + expect(post).toHaveBeenNthCalledWith(1, '/v1/cloud-projects/project-1/loop-items', { + title: 'Runtime task', + description: 'Track this task', + status: 'in_progress', + }) + expect(post).toHaveBeenNthCalledWith(2, '/v1/loop-items/WEG-1/tasks', { + ...task, + taskTitle: 'Runtime task', + }) + expect(post).toHaveBeenCalledTimes(2) + }) + + test('reuses a created board item when binding is retried', async () => { + const get = vi.fn().mockRejectedValue(new ApiError('Cloud context not found', 404)) + const post = vi + .fn() + .mockResolvedValueOnce(trackedItem) + .mockRejectedValueOnce(new Error('Temporary bind failure')) + .mockResolvedValueOnce(undefined) + const api = createDeliveryApi(clientWith({ get, post })) + const task = { deviceId: 'local-device', taskId: 'runtime-1' } + + await expect( + api.trackProjectTask('project-1', task, 'Runtime task', 'Track this task') + ).rejects.toThrow('Temporary bind failure') + await expect( + api.trackProjectTask('project-1', task, 'Runtime task', 'Track this task') + ).resolves.toEqual({ item: trackedItem }) + + expect(post).toHaveBeenCalledTimes(3) + expect(post.mock.calls.filter(([endpoint]) => endpoint.endsWith('/loop-items'))).toHaveLength(1) + }) + + test('updates tracking status through the existing loop item endpoint', async () => { + const reviewedItem = { ...trackedItem, status: 'in_review', version: 2 } + const context = { loop_item_id: trackedItem.id } as CloudTaskContext + const get = vi.fn().mockResolvedValueOnce(context).mockResolvedValueOnce(trackedItem) + const patch = vi.fn().mockResolvedValue(reviewedItem) + const api = createDeliveryApi(clientWith({ get, patch })) + + await expect( + api.updateTaskTrackingStatus({ deviceId: 'local-device', taskId: 'runtime-1' }, 'succeeded') + ).resolves.toEqual(reviewedItem) + expect(get).toHaveBeenNthCalledWith( + 1, + '/v1/runtime-tasks/cloud-context?device_id=local-device&task_id=runtime-1' + ) + expect(get).toHaveBeenNthCalledWith(2, '/v1/loop-items/WEG-1') + expect(patch).toHaveBeenCalledWith('/v1/loop-items/WEG-1', { + version: 1, + status: 'in_review', + }) + expect(patch).toHaveBeenCalledOnce() + }) +}) diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index 28ca4c1f85..f944ae37b1 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { HttpClient } from './http' +import { ApiError, type HttpClient } from './http' import type { RuntimeTaskAddress } from '@/types/api' import { openLocalFile } from '@/lib/local-terminal' @@ -206,6 +206,28 @@ export interface CloudMyWorkItem extends CloudLoopItem { has_active_task: boolean } +type TaskExecutionStatus = 'running' | 'succeeded' | 'failed' | 'cancelled' + +export function nextTaskTrackingStatus( + itemStatus: CloudLoopItem['status'], + executionStatus: TaskExecutionStatus +): CloudLoopItem['status'] | null { + if ( + executionStatus === 'running' && + (itemStatus === 'inbox' || itemStatus === 'pending' || itemStatus === 'in_review') + ) { + return 'in_progress' + } + if (executionStatus === 'succeeded' && itemStatus === 'in_progress') { + return 'in_review' + } + return null +} + +function projectTaskTrackingKey(projectId: CloudProjectIdInput, task: RuntimeTaskAddress): string { + return `${projectId}:${task.deviceId}:${task.taskId}` +} + export function createProjectTaskTrackingSingleFlight() { const requests = new Map>() @@ -214,7 +236,7 @@ export function createProjectTaskTrackingSingleFlight() { task: RuntimeTaskAddress, create: () => Promise<{ item: CloudLoopItem }> ): Promise<{ item: CloudLoopItem }> => { - const key = `${projectId}:${task.deviceId}:${task.taskId}` + const key = projectTaskTrackingKey(projectId, task) const existing = requests.get(key) if (existing) return existing @@ -230,8 +252,9 @@ export function createProjectTaskTrackingSingleFlight() { export function createDeliveryApi(client: HttpClient) { const trackProjectTaskOnce = createProjectTaskTrackingSingleFlight() + const pendingTrackedItems = new Map() - return { + const api = { listCloudProjects(): Promise<{ items: CloudProject[] }> { return client.get('/v1/cloud-projects') }, @@ -439,22 +462,51 @@ export function createDeliveryApi(client: HttpClient) { taskTitle: string, description: string ): Promise<{ item: CloudLoopItem }> { - return trackProjectTaskOnce(projectId, task, () => - client.post(`/v1/cloud-projects/${projectId}/tasks/track`, { - ...task, - taskTitle, - description, - }) - ) + return trackProjectTaskOnce(projectId, task, async () => { + const trackingKey = projectTaskTrackingKey(projectId, task) + try { + const existing = await api.findCloudContextForTask(task) + if (existing.loop_item_id) { + pendingTrackedItems.delete(trackingKey) + return { item: await api.getLoopItem(existing.loop_item_id) } + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 404) throw error + } + + const item = + pendingTrackedItems.get(trackingKey) ?? + (await api.createLoopItem(projectId, { + title: taskTitle, + description, + status: 'in_progress', + })) + pendingTrackedItems.set(trackingKey, item) + await api.bindTask(item.id, task, taskTitle) + pendingTrackedItems.delete(trackingKey) + return { item } + }) }, - updateTaskTrackingStatus( + async updateTaskTrackingStatus( task: RuntimeTaskAddress, - executionStatus: 'running' | 'succeeded' | 'failed' | 'cancelled' + executionStatus: TaskExecutionStatus ): Promise { - return client.patch('/v1/runtime-tasks/cloud-context/tracking-status', { - ...task, - executionStatus, - }) + let context: CloudTaskContext + try { + context = await api.findCloudContextForTask(task) + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null + throw error + } + if (!context.loop_item_id) return null + const item = await api.getLoopItem(context.loop_item_id) + const nextStatus = nextTaskTrackingStatus(item.status, executionStatus) + return nextStatus + ? api.updateLoopItem(item.id, { + version: item.version, + status: nextStatus, + }) + : item }, unbindCloudContext(task: RuntimeTaskAddress): Promise { return client.delete('/v1/runtime-tasks/cloud-context', task) @@ -547,4 +599,5 @@ export function createDeliveryApi(client: HttpClient) { return client.get(`/v1/deliveries/${deliveryId}`) }, } + return api } diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 7d1d1cd2df..659fc9ac79 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -2,6 +2,7 @@ import { convertFileSrc } from '@tauri-apps/api/core' import { createProjectTaskTrackingSingleFlight, + nextTaskTrackingStatus, type CloudLoopItemAttachment, type CloudLoopItem, type CloudProject, @@ -568,13 +569,7 @@ export function createLocalDeliveryApi( } if (!binding.loop_item_id) return null const item = await api.getLoopItem(binding.loop_item_id) - const nextStatus = - executionStatus === 'running' && - (item.status === 'inbox' || item.status === 'pending' || item.status === 'in_review') - ? 'in_progress' - : executionStatus === 'succeeded' && item.status === 'in_progress' - ? 'in_review' - : null + const nextStatus = nextTaskTrackingStatus(item.status, executionStatus) return nextStatus ? api.updateLoopItem(item.id, { version: item.version, status: nextStatus }) : item diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 8671391d12..7cd0fb1c03 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -24,6 +24,7 @@ import { projectSpaceApis, projectSpaceKey, projectSpaceRef, + runtimeCloudProjectId, } from '@/features/todo/projectSpaceSelection' import { hydrateLocalWorkItems, @@ -709,7 +710,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ additionalContext: cloudProjectAdditionalContext(submissionProject, submissionItem) ?? cloudAdditionalContext, - cloudProjectId: submissionProject?.id, + cloudProjectId: runtimeCloudProjectId(submissionProject), initialSupervisor: supervisorConfig, onRuntimeTaskCreated: address => { if (pendingTodoBinding) { diff --git a/wework/src/features/todo/projectSpaceSelection.test.ts b/wework/src/features/todo/projectSpaceSelection.test.ts index b8e9d99d33..8d42750e21 100644 --- a/wework/src/features/todo/projectSpaceSelection.test.ts +++ b/wework/src/features/todo/projectSpaceSelection.test.ts @@ -4,6 +4,7 @@ import { findProjectSpaceContextForTask, loadProjectSpaceOptions, projectSpaceRef, + runtimeCloudProjectId, type ProjectSpaceApi, } from './projectSpaceSelection' @@ -79,4 +80,12 @@ describe('projectSpaceSelection', () => { }) ).resolves.toEqual(context) }) + + test('only scopes runtime creation to backend project spaces', () => { + expect(runtimeCloudProjectId(project('space-local', 'Local board', 'local'))).toBeUndefined() + expect(runtimeCloudProjectId(project('space-cloud', 'Cloud board', 'backend'))).toBe( + 'space-cloud' + ) + expect(runtimeCloudProjectId(null)).toBeUndefined() + }) }) diff --git a/wework/src/features/todo/projectSpaceSelection.ts b/wework/src/features/todo/projectSpaceSelection.ts index 6273ebe1eb..4df9e76d5c 100644 --- a/wework/src/features/todo/projectSpaceSelection.ts +++ b/wework/src/features/todo/projectSpaceSelection.ts @@ -21,6 +21,10 @@ export function projectSpaceKey(ref: RuntimeProjectSpaceRef): string { return `${ref.projectStore}:${ref.projectId}` } +export function runtimeCloudProjectId(project: CloudProject | null): string | undefined { + return project?.project_store === 'backend' ? project.id : undefined +} + export function projectSpaceApis( services: WorkbenchServices | null | undefined ): ProjectSpaceApi[] {