diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index e52f563ba8..dd8fb3326b 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -4,7 +4,7 @@ """Shared cloud project endpoints.""" -from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status from sqlalchemy.orm import Session from app.api.dependencies import get_db @@ -25,6 +25,7 @@ CloudProjectMemberCreate, CloudProjectMemberResponse, CloudProjectMemberUpdate, + CloudProjectProviderCredentialResponse, CloudProjectResponse, CloudProjectUpdate, LocalBindingCreate, @@ -69,6 +70,23 @@ def get_cloud_project( return CloudProjectResponse.model_validate(project) +@router.get( + "/{project_id}/provider-credential", + response_model=CloudProjectProviderCredentialResponse, +) +def get_cloud_project_provider_credential( + project_id: int, + response: Response, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> CloudProjectProviderCredentialResponse: + response.headers["Cache-Control"] = "no-store" + token = cloud_project_service.get_provider_credential( + db, project_id, current_user.id + ) + return CloudProjectProviderCredentialResponse(token=token) + + @router.patch("/{project_id}", response_model=CloudProjectResponse) def update_cloud_project( project_id: int, diff --git a/backend/app/core/provider_credentials.py b/backend/app/core/provider_credentials.py new file mode 100644 index 0000000000..cd92df9c0b --- /dev/null +++ b/backend/app/core/provider_credentials.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Encrypted provider credentials stored with cloud project metadata.""" + +import base64 +import hashlib +import os +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from app.core.config import settings +from shared.utils.crypto import ( + CryptoConfigurationError, + decrypt_sensitive_data, +) + +TOKEN_KEY = "token" +CREDENTIAL_KEY = "credential" +CREDENTIAL_VERSION = 2 +CREDENTIAL_ALGORITHM = "aes-256-gcm" +LEGACY_CREDENTIAL_VERSION = 1 +LEGACY_CREDENTIAL_ALGORITHM = "aes-256-cbc" +NONCE_BYTES = 12 + + +def store_provider_config( + task_provider: str, + replacement: dict[str, object], + current: dict[str, object] | None = None, +) -> dict[str, object]: + """Normalize provider config and encrypt a supplied token.""" + config = dict(replacement) + if CREDENTIAL_KEY in config: + raise ValueError("encrypted provider credentials cannot be supplied") + config.pop("credential_configured", None) + token_supplied = TOKEN_KEY in config + token = config.pop(TOKEN_KEY, None) + if token is not None and not isinstance(token, str): + raise ValueError("provider token must be a string") + + if not token_supplied and current: + _preserve_credential(task_provider, current, config) + return config + + normalized_token = token.strip() if isinstance(token, str) else "" + if normalized_token and normalized_token != "***": + config[CREDENTIAL_KEY] = _encrypt_provider_token( + normalized_token, + _credential_context(task_provider, config), + ) + return config + + +def mask_provider_config(provider_config: object) -> dict[str, object]: + """Return non-sensitive provider settings for normal project responses.""" + if not isinstance(provider_config, dict): + return {} + config = dict(provider_config) + configured = isinstance(config.get(CREDENTIAL_KEY), dict) + config.pop(TOKEN_KEY, None) + config.pop(CREDENTIAL_KEY, None) + config["credential_configured"] = configured + return config + + +def decrypt_provider_token(task_provider: str, provider_config: object) -> str | None: + """Decrypt a stored cloud project provider token.""" + if not isinstance(provider_config, dict): + return None + credential = provider_config.get(CREDENTIAL_KEY) + if not isinstance(credential, dict): + return None + version = credential.get("version") + algorithm = credential.get("algorithm") + if ( + version == LEGACY_CREDENTIAL_VERSION + and algorithm == LEGACY_CREDENTIAL_ALGORITHM + ): + return _decrypt_legacy_provider_token(credential) + if version != CREDENTIAL_VERSION or algorithm != CREDENTIAL_ALGORITHM: + raise ValueError("unsupported provider credential format") + nonce = credential.get("nonce") + ciphertext = credential.get("ciphertext") + context = credential.get("context") + expected_context = _credential_context(task_provider, provider_config) + if not isinstance(nonce, str) or not nonce: + raise ValueError("provider credential nonce is required") + if not isinstance(ciphertext, str) or not ciphertext: + raise ValueError("provider credential ciphertext is required") + if not isinstance(context, str) or context != expected_context: + raise ValueError("provider credential context does not match project") + try: + token = AESGCM(_provider_credential_key()).decrypt( + base64.b64decode(nonce, validate=True), + base64.b64decode(ciphertext, validate=True), + context.encode("utf-8"), + ) + except (InvalidTag, ValueError) as exc: + raise ValueError("provider credential decryption failed") from exc + if not token: + raise ValueError("provider credential decryption failed") + return token.decode("utf-8") + + +def _preserve_credential( + task_provider: str, + current: dict[str, object], + replacement: dict[str, object], +) -> None: + credential = current.get(CREDENTIAL_KEY) + if not isinstance(credential, dict): + return + if _credential_context(task_provider, current) != _credential_context( + task_provider, replacement + ): + raise ValueError("provider token is required when repository or domain changes") + replacement[CREDENTIAL_KEY] = credential + + +def _credential_context(task_provider: str, config: dict[str, Any]) -> str: + repository = str(config.get("repository") or "").strip() + default_domain = "github.com" if task_provider == "github" else "gitlab.com" + domain = str(config.get("domain") or default_domain).strip() + return f"{task_provider}:{domain}:{repository}" + + +def _provider_credential_key() -> bytes: + material = f"wegent-cloud-project-provider:{settings.SECRET_KEY}".encode("utf-8") + return hashlib.sha256(material).digest() + + +def _encrypt_provider_token(token: str, context: str) -> dict[str, object]: + nonce = os.urandom(NONCE_BYTES) + ciphertext = AESGCM(_provider_credential_key()).encrypt( + nonce, + token.encode("utf-8"), + context.encode("utf-8"), + ) + return { + "version": CREDENTIAL_VERSION, + "algorithm": CREDENTIAL_ALGORITHM, + "context": context, + "nonce": base64.b64encode(nonce).decode("ascii"), + "ciphertext": base64.b64encode(ciphertext).decode("ascii"), + } + + +def _decrypt_legacy_provider_token(credential: dict[str, object]) -> str: + ciphertext = credential.get("ciphertext") + if not isinstance(ciphertext, str) or not ciphertext: + raise ValueError("provider credential ciphertext is required") + try: + token = decrypt_sensitive_data(ciphertext) + except CryptoConfigurationError as exc: + raise ValueError( + "legacy provider credentials require GIT_TOKEN_AES_KEY and " + "GIT_TOKEN_AES_IV" + ) from exc + if not token or token == ciphertext: + raise ValueError("provider credential decryption failed") + return token diff --git a/backend/app/mcp_server/tools/delivery.py b/backend/app/mcp_server/tools/delivery.py index 95c55a5313..f837d7fc5c 100644 --- a/backend/app/mcp_server/tools/delivery.py +++ b/backend/app/mcp_server/tools/delivery.py @@ -61,6 +61,18 @@ def _serialize_collaborator(row: dict[str, Any]) -> dict[str, Any]: } +def _serialize_project(project: Any) -> dict[str, Any]: + return { + "id": project.id, + "key": project.project_key, + "name": project.name, + "description": project.description, + "projectStore": project.project_store, + "taskProvider": project.task_provider, + "providerConfig": project.provider_config, + } + + @mcp_tool( name="list_loop_item_deliveries", description="List immutable deliveries available for a TODO or Loop Item.", @@ -150,24 +162,18 @@ def read_delivery_asset(asset_id: str, token_info: MCPAuthInfo) -> dict[str, Any @mcp_tool( name="list_cloud_projects", - description="List shared cloud projects the current user can access.", + description=( + "List shared cloud project spaces and their independent taskProvider. " + "For github or gitlab projects, use the local wegent_tasks MCP tools for " + "TODO/Issue operations; never copy the project or create a Backend TODO." + ), server="delivery", exclude_params=["token_info"], ) def list_cloud_projects(token_info: MCPAuthInfo) -> dict[str, Any]: with SessionLocal() as db: projects = cloud_project_service.list_accessible(db, token_info.user_id) - return { - "projects": [ - { - "id": project.id, - "key": project.project_key, - "name": project.name, - "description": project.description, - } - for project in projects - ] - } + return {"projects": [_serialize_project(project) for project in projects]} @mcp_tool( @@ -193,12 +199,7 @@ def create_cloud_project( name=name, project_key=project_key, description=description ) project = cloud_project_service.create(db, token_info.user_id, values) - return { - "id": project.id, - "key": project.project_key, - "name": project.name, - "description": project.description, - } + return _serialize_project(project) @mcp_tool( @@ -267,7 +268,10 @@ def read_cloud_file(file_id: int, token_info: MCPAuthInfo) -> dict[str, Any]: @mcp_tool( name="list_cloud_todos", - description="List TODOs and their current state in an authorized cloud project.", + description=( + "List Backend-native TODOs only when the cloud project's taskProvider is " + "local. GitHub and GitLab Issues are handled by wegent_tasks." + ), server="delivery", exclude_params=["token_info"], ) @@ -304,7 +308,9 @@ def get_cloud_todo(item_id: str, token_info: MCPAuthInfo) -> dict[str, Any]: @mcp_tool( name="create_cloud_todo", description=( - "Create a TODO in an authorized cloud project. Status must be one of " + "Create a Backend-native TODO only when the cloud project's taskProvider " + "is local. GitHub and GitLab projects must use wegent_tasks.create_todo. " + "Status must be one of " "inbox, pending, in_progress, in_review, completed; priority one of " "none, low, medium, high, urgent; due_at is an ISO 8601 datetime." ), @@ -528,10 +534,23 @@ def resolve_cloud_reference(reference: str, token_info: MCPAuthInfo) -> dict[str return {"error": "Invalid cloud project id"} if len(parts) == 1: + with SessionLocal() as db: + project = cloud_project_service.get(db, project_id, token_info.user_id) + project_data = _serialize_project(project) + todos = ( + list_cloud_todos(project_id, token_info) + if project.task_provider == "local" + else { + "items": [], + "taskProvider": project.task_provider, + "todoTool": "wegent_tasks.create_todo", + } + ) return { "projectId": project_id, + "project": project_data, "workspace": list_cloud_workspace(project_id, token_info), - "todos": list_cloud_todos(project_id, token_info), + "todos": todos, } if len(parts) != 3: return {"error": "Unsupported cloud reference path"} diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py index 135b2657d1..46c31129cf 100644 --- a/backend/app/models/delivery.py +++ b/backend/app/models/delivery.py @@ -22,6 +22,7 @@ from sqlalchemy.engine import Connection from sqlalchemy.sql import func +from app.core.provider_credentials import mask_provider_config from app.db.base import Base from shared.models.db.types import big_integer_id_type @@ -147,6 +148,26 @@ def tags(self) -> list[str]: return [] return [str(tag) for tag in tags] + @property + def project_store(self) -> str: + return "backend" + + @property + def task_provider(self) -> str: + metadata = self.metadata_json + if not isinstance(metadata, dict): + return "local" + provider = metadata.get("task_provider") + return provider if provider in {"local", "github", "gitlab"} else "local" + + @property + def provider_config(self) -> dict[str, object]: + metadata = self.metadata_json + if not isinstance(metadata, dict): + return {} + config = metadata.get("provider_config") + return mask_provider_config(config) + def __init__(self, **kwargs: object) -> None: kwargs.setdefault("status", "active") kwargs.setdefault("next_item_number", 1) diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index dbab6c5064..86f42a2014 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -5,7 +5,7 @@ """Schemas for shared cloud projects and local execution bindings.""" from datetime import datetime -from typing import Annotated +from typing import Annotated, Literal from pydantic import ( BaseModel, @@ -16,10 +16,44 @@ model_validator, ) +from app.core.provider_credentials import mask_provider_config from app.schemas.base_role import BaseRole from app.schemas.tagging import MAX_TAGS_PER_ITEM, normalize_tags SnowflakeId = Annotated[str, BeforeValidator(str)] +TaskProvider = Literal["local", "github", "gitlab"] + + +def _normalize_repository(task_provider: str, repository: str) -> str: + normalized = repository.strip().strip("/") + if task_provider == "gitlab": + normalized = normalized.split("/-/", 1)[0] + if normalized.endswith(".git"): + normalized = normalized[:-4] + return normalized + + +def normalize_provider_config( + task_provider: str, provider_config: dict[str, object] +) -> dict[str, object]: + config = dict(provider_config) + if task_provider == "local": + return {} + repository = config.get("repository") + if not isinstance(repository, str) or not repository.strip(): + raise ValueError("provider_config.repository is required") + normalized_repository = _normalize_repository(task_provider, repository) + if not normalized_repository: + raise ValueError("provider_config.repository is required") + config["repository"] = normalized_repository + if "credential" in config: + raise ValueError("encrypted provider credentials cannot be supplied") + token = config.get("token") + if token is not None and not isinstance(token, str): + raise ValueError("provider token must be a string") + if isinstance(token, str): + config["token"] = token.strip() + return config class CloudProjectCreate(BaseModel): @@ -28,17 +62,27 @@ class CloudProjectCreate(BaseModel): ) name: str = Field(min_length=1, max_length=100) description: str = "" + task_provider: TaskProvider = "local" + provider_config: dict[str, object] = Field(default_factory=dict) @field_validator("project_key") @classmethod def normalize_project_key(cls, value: str | None) -> str | None: return value.upper() if value else None + @model_validator(mode="after") + def validate_provider(self) -> "CloudProjectCreate": + self.provider_config = normalize_provider_config( + self.task_provider, self.provider_config + ) + return self + class CloudProjectUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) description: str | None = None tags: list[str] | None = Field(default=None, max_length=MAX_TAGS_PER_ITEM) + provider_config: dict[str, object] | None = None version: int = Field(ge=1) @field_validator("tags", mode="before") @@ -46,6 +90,20 @@ class CloudProjectUpdate(BaseModel): def normalize_tag_list(cls, value: object) -> object: return None if value is None else normalize_tags(value) + @model_validator(mode="after") + def validate_provider(self) -> "CloudProjectUpdate": + if self.provider_config is not None: + # The provider kind is immutable. The service validates this config + # against the project's current provider before persisting it. + if "credential" in self.provider_config: + raise ValueError("encrypted provider credentials cannot be supplied") + token = self.provider_config.get("token") + if token is not None and not isinstance(token, str): + raise ValueError("provider token must be a string") + if isinstance(token, str): + self.provider_config["token"] = token.strip() + return self + class CloudProjectResponse(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -55,6 +113,9 @@ class CloudProjectResponse(BaseModel): project_key: str name: str description: str + project_store: Literal["backend"] = "backend" + task_provider: TaskProvider = "local" + provider_config: dict[str, object] = Field(default_factory=dict) created_by_user_id: int status: str tags: list[str] = [] @@ -65,11 +126,19 @@ class CloudProjectResponse(BaseModel): @model_validator(mode="before") @classmethod def populate_tags(cls, value: object) -> object: - """Fill tags from the metadata JSON when the input has no tags key.""" - if isinstance(value, dict) and "tags" not in value: + """Fill project routing fields from the metadata JSON.""" + if isinstance(value, dict): metadata = value.get("metadata_json") - tags = metadata.get("tags") if isinstance(metadata, dict) else None - return {**value, "tags": normalize_tags(tags)} + metadata = metadata if isinstance(metadata, dict) else {} + return { + **value, + "project_store": "backend", + "task_provider": metadata.get("task_provider", "local"), + "provider_config": mask_provider_config( + metadata.get("provider_config", {}) + ), + "tags": normalize_tags(metadata.get("tags")), + } return value @@ -77,6 +146,10 @@ class CloudProjectListResponse(BaseModel): items: list[CloudProjectResponse] +class CloudProjectProviderCredentialResponse(BaseModel): + token: str + + class LocalBindingCreate(BaseModel): local_project_id: int device_id: str | None = Field(default=None, max_length=100) diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py index 40dda7dfbb..c14a276d3f 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -13,6 +13,10 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core.provider_credentials import ( + decrypt_provider_token, + store_provider_config, +) from app.models.cloud_project import CloudProject, CloudProjectLocalBinding from app.models.project import Project from app.models.resource_member import MemberStatus, ResourceMember @@ -25,6 +29,7 @@ CloudProjectMemberUpdate, CloudProjectUpdate, LocalBindingCreate, + normalize_provider_config, ) from app.services.cloud_projects.access import require_cloud_project_role @@ -52,6 +57,12 @@ def create( self, db: Session, user_id: int, values: CloudProjectCreate ) -> CloudProject: public_id = str(uuid.uuid4()) + try: + provider_config = store_provider_config( + values.task_provider, values.provider_config + ) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc project = CloudProject( public_id=public_id, project_key=values.project_key @@ -60,6 +71,12 @@ def create( description=values.description, created_by_user_id=user_id, storage_prefix=f"projects/{public_id}", + metadata_json={ + "project_store": "backend", + "task_provider": values.task_provider, + "provider_config": provider_config, + "tags": [], + }, ) db.add(project) try: @@ -106,6 +123,27 @@ def list_accessible(self, db: Session, user_id: int) -> list[CloudProject]: def get(self, db: Session, project_id: int, user_id: int) -> CloudProject: return require_cloud_project_role(db, project_id, user_id).project + def get_provider_credential( + self, db: Session, project_id: int, user_id: int + ) -> str: + project = require_cloud_project_role( + db, project_id, user_id, BaseRole.Developer + ).project + metadata = ( + project.metadata_json if isinstance(project.metadata_json, dict) else {} + ) + try: + token = decrypt_provider_token( + project.task_provider, metadata.get("provider_config") + ) + except ValueError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + if not token: + raise HTTPException( + status.HTTP_409_CONFLICT, "Provider credential is not configured" + ) + return token + def update( self, db: Session, @@ -117,11 +155,40 @@ def update( db, project_id, user_id, BaseRole.Maintainer ).project updates = values.model_dump(exclude={"version"}, exclude_none=True) - if "tags" in values.model_fields_set and values.tags is not None: - # The project tag registry lives inside the metadata JSON column; - # merge so other metadata keys survive the update. + if ( + "tags" in values.model_fields_set + or "provider_config" in values.model_fields_set + ): metadata = dict(project.metadata_json or {}) - metadata["tags"] = updates.pop("tags") + if "tags" in values.model_fields_set and values.tags is not None: + metadata["tags"] = updates.pop("tags") + if ( + "provider_config" in values.model_fields_set + and values.provider_config is not None + ): + provider = metadata.get("task_provider", "local") + current_config = metadata.get("provider_config") + current_config = ( + current_config if isinstance(current_config, dict) else {} + ) + try: + provider_config = normalize_provider_config( + str(provider), values.provider_config + ) + metadata["provider_config"] = ( + {} + if provider == "local" + else store_provider_config( + str(provider), + provider_config, + current_config, + ) + ) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc) + ) from exc + updates.pop("provider_config", None) updates["metadata_json"] = metadata updated = ( db.query(CloudProject) diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 768efac96a..10a7a0e42d 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -46,6 +46,26 @@ class LoopItemService: + def _require_internal_task_project( + self, + db: Session, + cloud_project_id: int, + user_id: int, + required_role: BaseRole = BaseRole.Reporter, + ) -> CloudProject: + project = require_cloud_project_role( + db, cloud_project_id, user_id, required_role + ).project + if project.task_provider != "local": + raise HTTPException( + status.HTTP_409_CONFLICT, + ( + f"Project tasks are provided by {project.task_provider}; " + "use the local Issue provider" + ), + ) + return project + def ensure_collaborator( self, db: Session, @@ -141,7 +161,9 @@ def create( user_id: int, values: LoopItemCreate, ) -> LoopItem: - require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer) + self._require_internal_task_project( + db, cloud_project_id, user_id, BaseRole.Developer + ) if values.parent_id is not None: self._require_parent(db, values.parent_id, cloud_project_id) project = ( @@ -171,7 +193,7 @@ def create( return item def list(self, db: Session, cloud_project_id: int, user_id: int) -> list[LoopItem]: - require_cloud_project_role(db, cloud_project_id, user_id) + self._require_internal_task_project(db, cloud_project_id, user_id) return ( db.query(LoopItem) .filter( @@ -191,7 +213,9 @@ def reorder( ) -> list[LoopItem]: """Persist the manual order of the TODOs in one board lane.""" - require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer) + self._require_internal_task_project( + db, cloud_project_id, user_id, BaseRole.Developer + ) if values.parent_id is None: # MySQL stores unset parent ids as empty strings, so match both. parent_filter = or_(LoopItem.parent_id.is_(None), LoopItem.parent_id == "") diff --git a/backend/app/services/runtime_work_service.py b/backend/app/services/runtime_work_service.py index 28f9397f6c..28789be3db 100644 --- a/backend/app/services/runtime_work_service.py +++ b/backend/app/services/runtime_work_service.py @@ -3533,10 +3533,15 @@ def _message_with_application_context( entries.append( "[projectSpaceCapability]\n" "The user activated the Wegent project-space capability.\n" - "Use the wegent_delivery MCP server for project-space operations.\n" - "wegent_delivery is a server id, not a callable tool.\n" - "Use list_cloud_projects to list projects and create_cloud_project " - "to create one.\n" + "Project storage and task source are independent.\n" + "Use wegent_tasks for local project spaces and for GitHub or GitLab " + "Issues, even when the project space is stored in the Backend.\n" + "Use wegent_delivery for cloud project metadata, files, deliveries, " + "and Backend-native TODOs only.\n" + "wegent_delivery and wegent_tasks are server ids, not callable tools.\n" + "List both sources when resolving a project name.\n" + "Never create or copy a cloud project merely because a local project " + "is not returned by list_cloud_projects.\n" "Use resolve_cloud_reference to resolve cloud:// references.\n" "MCP resources describe addressable data; do not use " "list_mcp_resources to discover tools." diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 64d3c04492..b303936415 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -12,7 +12,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from app.models.delivery import Delivery, DeliveryAsset +from app.models.delivery import CloudProject, Delivery, DeliveryAsset from app.models.project import Project from app.models.user import User from app.services.cloud_files import cloud_file_service @@ -122,6 +122,161 @@ def test_cloud_project_generates_key_when_omitted( assert 2 <= len(created.json()["project_key"]) <= 16 +def test_cloud_project_persists_external_task_provider_and_encrypted_token( + test_client: TestClient, + test_db: Session, + test_token: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GIT_TOKEN_AES_KEY", raising=False) + monkeypatch.delenv("GIT_TOKEN_AES_IV", raising=False) + + created = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "name": "GitHub issues", + "task_provider": "github", + "provider_config": { + "repository": "wecode-ai/Wegent", + "domain": "github.com", + "token": "github-secret", + }, + }, + ) + + assert created.status_code == 201 + assert created.json()["project_store"] == "backend" + assert created.json()["task_provider"] == "github" + assert created.json()["provider_config"] == { + "repository": "wecode-ai/Wegent", + "domain": "github.com", + "credential_configured": True, + } + + listed = test_client.get("/api/v1/cloud-projects", headers=_auth(test_token)) + match = next( + item for item in listed.json()["items"] if item["id"] == created.json()["id"] + ) + assert match["task_provider"] == "github" + assert match["provider_config"]["repository"] == "wecode-ai/Wegent" + assert match["provider_config"]["credential_configured"] is True + + stored = ( + test_db.query(CloudProject) + .filter(CloudProject.id == created.json()["id"]) + .one() + ) + serialized = str(stored.metadata_json) + assert "github-secret" not in serialized + assert "ciphertext" in serialized + credential_metadata = stored.metadata_json["provider_config"]["credential"] + assert credential_metadata["version"] == 2 + assert credential_metadata["algorithm"] == "aes-256-gcm" + + credential = test_client.get( + f"/api/v1/cloud-projects/{created.json()['id']}/provider-credential", + headers=_auth(test_token), + ) + assert credential.status_code == 200 + assert credential.json() == {"token": "github-secret"} + assert credential.headers["cache-control"] == "no-store" + + +def test_cloud_project_can_add_missing_provider_token( + test_client: TestClient, test_token: str +) -> None: + created = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "name": "Existing GitLab project", + "task_provider": "gitlab", + "provider_config": { + "repository": "group/project", + "domain": "gitlab.example.com", + "api_base": "https://gitlab.example.com/api/v4", + }, + }, + ).json() + + updated = test_client.patch( + f"/api/v1/cloud-projects/{created['id']}", + headers=_auth(test_token), + json={ + "version": created["version"], + "provider_config": { + "repository": "group/project", + "domain": "gitlab.example.com", + "api_base": "https://gitlab.example.com/api/v4", + "token": "gitlab-secret", + }, + }, + ) + + assert updated.status_code == 200 + assert updated.json()["provider_config"]["credential_configured"] is True + credential = test_client.get( + f"/api/v1/cloud-projects/{created['id']}/provider-credential", + headers=_auth(test_token), + ) + assert credential.json() == {"token": "gitlab-secret"} + + +def test_cloud_project_normalizes_gitlab_web_page_repository( + test_client: TestClient, test_token: str +) -> None: + created = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "name": "GitLab web URL", + "task_provider": "gitlab", + "provider_config": { + "repository": "hongyu91/tab-prompt/-/issues", + "domain": "gitlab.example.com", + "api_base": "https://gitlab.example.com/api/v4", + "token": "gitlab-secret", + }, + }, + ) + + assert created.status_code == 201 + assert created.json()["provider_config"]["repository"] == "hongyu91/tab-prompt" + + +def test_external_cloud_project_rejects_internal_loop_item_routes( + test_client: TestClient, test_token: str +) -> None: + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={ + "name": "GitLab issues", + "task_provider": "gitlab", + "provider_config": { + "repository": "group/project", + "domain": "gitlab.example.com", + "api_base": "https://gitlab.example.com/api/v4", + }, + }, + ).json() + + listed = test_client.get( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(test_token), + ) + created = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/loop-items", + headers=_auth(test_token), + json={"title": "Must be created in GitLab"}, + ) + + assert listed.status_code == 409 + assert created.status_code == 409 + assert "use the local Issue provider" in created.json()["detail"] + + def test_cloud_project_can_link_local_workspace( test_client: TestClient, test_db: Session, diff --git a/backend/tests/mcp_server/test_delivery_todo_tools.py b/backend/tests/mcp_server/test_delivery_todo_tools.py index b3dce36ab5..f8cc2a4196 100644 --- a/backend/tests/mcp_server/test_delivery_todo_tools.py +++ b/backend/tests/mcp_server/test_delivery_todo_tools.py @@ -328,6 +328,8 @@ def test_create_cloud_project_with_name_only(owner_info: SimpleNamespace) -> Non assert created["name"] == "Side Project" assert created["description"] == "" + assert created["projectStore"] == "backend" + assert created["taskProvider"] == "local" # The key is generated from the name when project_key is omitted. assert created["key"].startswith("SIDEPROJ") @@ -375,9 +377,40 @@ def test_resolve_cloud_reference_without_project_id_lists_accessible_projects( resolved = delivery_tools.resolve_cloud_reference("cloud://projects", owner_info) assert "error" not in resolved - assert { - "id": project.id, - "key": project.project_key, - "name": project.name, - "description": project.description, - } in resolved["projects"] + matched = next(item for item in resolved["projects"] if item["id"] == project.id) + assert matched["key"] == project.project_key + assert matched["name"] == project.name + assert matched["projectStore"] == "backend" + assert matched["taskProvider"] == "local" + + +def test_external_cloud_project_routes_todos_to_local_task_mcp( + test_db: Session, + project: CloudProject, + owner_info: SimpleNamespace, +) -> None: + project.metadata_json = { + "project_store": "backend", + "task_provider": "gitlab", + "provider_config": {"repository": "group/repo"}, + } + test_db.commit() + + listed = delivery_tools.list_cloud_projects(owner_info) + matched = next(item for item in listed["projects"] if item["id"] == project.id) + assert matched["projectStore"] == "backend" + assert matched["taskProvider"] == "gitlab" + + with pytest.raises(HTTPException) as exc_info: + delivery_tools.create_cloud_todo(project.id, "Wrong store", owner_info) + assert exc_info.value.status_code == 409 + + resolved = delivery_tools.resolve_cloud_reference( + f"cloud://projects/{project.id}", owner_info + ) + assert resolved["project"]["taskProvider"] == "gitlab" + assert resolved["todos"] == { + "items": [], + "taskProvider": "gitlab", + "todoTool": "wegent_tasks.create_todo", + } diff --git a/backend/tests/services/test_runtime_work_service.py b/backend/tests/services/test_runtime_work_service.py index 127c4395f3..9b564ff1b8 100644 --- a/backend/tests/services/test_runtime_work_service.py +++ b/backend/tests/services/test_runtime_work_service.py @@ -3939,6 +3939,6 @@ def test_message_with_cloud_reference_activates_project_space_capability() -> No ) assert "[projectSpaceCapability]" in message - assert "wegent_delivery is a server id, not a callable tool" in message - assert "create_cloud_project" in message + assert "wegent_delivery and wegent_tasks are server ids" in message + assert "Never create or copy a cloud project" in message assert "do not use list_mcp_resources to discover tools" in message diff --git a/executor/Cargo.lock b/executor/Cargo.lock index 6c2eef5375..2cf918d698 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -14,6 +14,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -25,6 +35,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -378,9 +402,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -695,6 +729,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -1343,6 +1387,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.81" @@ -1423,6 +1473,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-pty" version = "0.9.0" @@ -2436,6 +2498,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2466,6 +2538,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2620,6 +2703,7 @@ name = "wegent-executor" version = "1.8.5" dependencies = [ "aes", + "aes-gcm", "axum", "base64", "cbc", @@ -2649,6 +2733,7 @@ dependencies = [ "toml_edit", "tower", "url", + "uuid", "zip", ] diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 8143e1c1b8..943bda29c0 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -25,6 +25,7 @@ dev-reload = ["dep:ctrlc", "dep:notify"] [dependencies] axum = { version = "0.8", features = ["multipart", "ws"] } aes = "0.8" +aes-gcm = "0.10" base64 = "0.22" cbc = "0.1" chrono = { version = "0.4", default-features = false, features = ["clock"] } @@ -51,6 +52,7 @@ tokio = { version = "1.48", features = ["io-std", "io-util", "macros", "net", "p tokio-tungstenite = "0.21" toml_edit = "0.22" url = "2" +uuid = { version = "1", features = ["v4"] } zip = { version = "0.6", default-features = false, features = ["deflate"] } [dev-dependencies] diff --git a/executor/src/agents/mod.rs b/executor/src/agents/mod.rs index 457aa8276b..11bf84ef8a 100644 --- a/executor/src/agents/mod.rs +++ b/executor/src/agents/mod.rs @@ -11,7 +11,7 @@ mod claude_options; mod codex; mod codex_log_db; mod dify; -mod git_auth; +pub(crate) mod git_auth; mod git_workspace; mod image_validator; pub mod interactive_mcp; @@ -262,7 +262,8 @@ impl AgentProcessEngine { impl AgentEngine for AgentProcessEngine { type RunFuture = Pin + Send>>; - fn run(&self, request: ExecutionRequest) -> Self::RunFuture { + fn run(&self, mut request: ExecutionRequest) -> Self::RunFuture { + crate::task_runtime::mcp::ensure_task_mcp_server(&mut request); let planner = self.planner.clone(); Box::pin(async move { let agent_kind = request.resolved_agent_kind(); @@ -346,13 +347,14 @@ impl AgentEngine for AgentProcessEngine { fn run_with_events( &self, - request: ExecutionRequest, + mut request: ExecutionRequest, sink: S, builder: ResponsesEventBuilder, ) -> Pin + Send>> where S: EventSink, { + crate::task_runtime::mcp::ensure_task_mcp_server(&mut request); let planner = self.planner.clone(); Box::pin(async move { let agent_kind = request.resolved_agent_kind(); diff --git a/executor/src/bin/wegent-executor.rs b/executor/src/bin/wegent-executor.rs index 4ad2309fe9..057360689c 100644 --- a/executor/src/bin/wegent-executor.rs +++ b/executor/src/bin/wegent-executor.rs @@ -22,6 +22,13 @@ fn main() { } return; } + if wegent_executor::task_runtime::mcp::is_task_mcp_command() { + if let Err(error) = runtime().block_on(wegent_executor::task_runtime::mcp::run()) { + eprintln!("task MCP server failed: {error}"); + std::process::exit(1); + } + return; + } let args = match CliArgs::parse_from(env::args()) { Ok(args) => args, diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 09a7391743..bdd8ef1cb1 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -29,6 +29,7 @@ pub mod runtime_work; pub mod server; pub mod services; pub mod stream; +pub mod task_runtime; pub mod version; #[cfg(test)] diff --git a/executor/src/local/app_ipc.rs b/executor/src/local/app_ipc.rs index 5895171688..377de31f11 100644 --- a/executor/src/local/app_ipc.rs +++ b/executor/src/local/app_ipc.rs @@ -26,6 +26,10 @@ use crate::{ }, logging::{format_executor_log, reserve_executor_stdout_for_protocol, write_executor_log_line}, runtime_work::RuntimeWorkRpcHandler, + task_runtime::{ + BinaryInput, DeliveryCreate, ProjectCreate, ProjectDescriptor, ProjectUpdate, + RuntimeTaskAddress, TaskCreate, TaskReorder, TaskRuntime, TaskUpdate, + }, version::get_version, }; @@ -373,6 +377,18 @@ impl AppIpcServer { return self.handle_device_command(params).await; } + if method.starts_with("projects.") + || method.starts_with("external_projects.") + || method.starts_with("todos.") + || method.starts_with("external_todos.") + || method.starts_with("runtime_tasks.") + || method.starts_with("files.") + || method.starts_with("attachments.") + || method.starts_with("deliveries.") + { + return handle_task_runtime_request(method, params).await; + } + if method.starts_with("runtime.") { let Some(handler) = &self.runtime_work_handler else { return Err(AppIpcError::new( @@ -657,6 +673,399 @@ impl AppIpcServer { } } +async fn handle_task_runtime_request(method: &str, params: Value) -> Result { + let runtime = TaskRuntime::from_env().map_err(task_runtime_error)?; + match method { + "projects.list" => { + serialize_task_value(runtime.list_projects().map_err(task_runtime_error)?) + } + "projects.create" => { + let input = serde_json::from_value::(params) + .map_err(|error| AppIpcError::new("bad_request", error.to_string()))?; + serialize_task_value(runtime.create_project(input).map_err(task_runtime_error)?) + } + "projects.update" => { + let project_id = required_task_string(¶ms, "project_id")?; + let input = task_input::(¶ms, "project")?; + serialize_task_value( + runtime + .update_project(project_id, input) + .map_err(task_runtime_error)?, + ) + } + "external_projects.configure" => { + let project = task_input::(¶ms, "project")?; + serialize_task_value( + runtime + .configure_external_project(project) + .map_err(task_runtime_error)?, + ) + } + "external_todos.list" => { + let project = task_input::(¶ms, "project")?; + serialize_task_value( + runtime + .list_external_tasks(project) + .await + .map_err(task_runtime_error)?, + ) + } + "external_todos.get" => { + let project = task_input::(¶ms, "project")?; + let task_id = required_task_string(¶ms, "task_id")?; + serialize_task_value( + runtime + .get_external_task(project, task_id) + .await + .map_err(task_runtime_error)?, + ) + } + "external_todos.create" => { + let project = task_input::(¶ms, "project")?; + let input = task_input::(¶ms, "todo")?; + serialize_task_value( + runtime + .create_external_task(project, input) + .await + .map_err(task_runtime_error)?, + ) + } + "external_todos.update" => { + let project = task_input::(¶ms, "project")?; + let task_id = required_task_string(¶ms, "task_id")?; + let input = task_input::(¶ms, "todo")?; + serialize_task_value( + runtime + .update_external_task(project, task_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.list" => { + let project_id = required_task_string(¶ms, "project_id")?; + serialize_task_value( + runtime + .list_tasks(project_id) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.get" => { + let project_id = required_task_string(¶ms, "project_id")?; + let task_id = required_task_string(¶ms, "task_id")?; + serialize_task_value( + runtime + .get_task(project_id, task_id) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.create" => { + let project_id = required_task_string(¶ms, "project_id")?; + let input = serde_json::from_value::( + params + .get("todo") + .cloned() + .unwrap_or_else(|| params.clone()), + ) + .map_err(|error| AppIpcError::new("bad_request", error.to_string()))?; + serialize_task_value( + runtime + .create_task(project_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.update" => { + let project_id = required_task_string(¶ms, "project_id")?; + let task_id = required_task_string(¶ms, "task_id")?; + let input = serde_json::from_value::( + params + .get("todo") + .cloned() + .unwrap_or_else(|| params.clone()), + ) + .map_err(|error| AppIpcError::new("bad_request", error.to_string()))?; + serialize_task_value( + runtime + .update_task(project_id, task_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.comment" => { + let project_id = required_task_string(¶ms, "project_id")?; + let task_id = required_task_string(¶ms, "task_id")?; + let body = required_task_string(¶ms, "body")?; + serialize_task_value( + runtime + .add_comment(project_id, task_id, body) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.reorder" => { + let project_id = required_task_string(¶ms, "project_id")?; + let input = serde_json::from_value::( + params + .get("reorder") + .cloned() + .unwrap_or_else(|| params.clone()), + ) + .map_err(|error| AppIpcError::new("bad_request", error.to_string()))?; + serialize_task_value( + runtime + .reorder_tasks(project_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "todos.bindings" => { + let task_id = required_task_string(¶ms, "task_id")?; + serialize_task_value( + runtime + .list_task_bindings(task_id) + .map_err(task_runtime_error)?, + ) + } + "todos.bind" | "projects.bind_task" => { + let project_id = required_task_string(¶ms, "project_id")?; + let item_id = params.get("item_id").and_then(Value::as_str); + let input = serde_json::from_value::( + params + .get("task") + .cloned() + .unwrap_or_else(|| params.clone()), + ) + .map_err(|error| AppIpcError::new("bad_request", error.to_string()))?; + serialize_task_value( + runtime + .bind_task(project_id, item_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "runtime_tasks.context" => { + let device_id = required_task_string(¶ms, "device_id")?; + let runtime_task_id = required_task_string(¶ms, "task_id")?; + serialize_task_value( + runtime + .find_task_binding(device_id, runtime_task_id) + .map_err(task_runtime_error)?, + ) + } + "runtime_tasks.unbind" => { + let device_id = required_task_string(¶ms, "device_id")?; + let runtime_task_id = required_task_string(¶ms, "task_id")?; + runtime + .unbind_task(device_id, runtime_task_id) + .map_err(task_runtime_error)?; + Ok(json!({"unbound": true})) + } + "files.list" => { + let project_id = required_task_string(¶ms, "project_id")?; + serialize_task_value( + runtime + .list_project_files(project_id) + .map_err(task_runtime_error)?, + ) + } + "files.create_folder" => { + let project_id = required_task_string(¶ms, "project_id")?; + let path = required_task_string(¶ms, "path")?; + serialize_task_value( + runtime + .create_project_folder(project_id, path) + .map_err(task_runtime_error)?, + ) + } + "files.upload" => { + let project_id = required_task_string(¶ms, "project_id")?; + let path = params.get("path").and_then(Value::as_str); + let input = task_input::(¶ms, "file")?; + serialize_task_value( + runtime + .upload_project_file(project_id, path, input) + .map_err(task_runtime_error)?, + ) + } + "files.access" => { + let file_id = required_task_string(¶ms, "file_id")?; + Ok(json!({ + "path": runtime + .project_file_path(file_id) + .map_err(task_runtime_error)? + })) + } + "files.move" => { + let file_id = required_task_string(¶ms, "file_id")?; + let path = required_task_string(¶ms, "path")?; + let version = required_task_i64(¶ms, "version")?; + serialize_task_value( + runtime + .move_project_file(file_id, path, version) + .map_err(task_runtime_error)?, + ) + } + "files.delete" => { + let file_id = required_task_string(¶ms, "file_id")?; + let recursive = params + .get("recursive") + .and_then(Value::as_bool) + .unwrap_or(false); + runtime + .delete_project_file(file_id, recursive) + .map_err(task_runtime_error)?; + Ok(json!({"deleted": true})) + } + "attachments.list" => { + let item_id = required_task_string(¶ms, "item_id")?; + serialize_task_value( + runtime + .list_task_attachments(item_id) + .map_err(task_runtime_error)?, + ) + } + "attachments.add" => { + let project_id = required_task_string(¶ms, "project_id")?; + let item_id = required_task_string(¶ms, "item_id")?; + let input = task_input::(¶ms, "file")?; + serialize_task_value( + runtime + .add_task_attachment(project_id, item_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "attachments.access" => { + let attachment_id = required_task_string(¶ms, "attachment_id")?; + Ok(json!({ + "path": runtime + .task_attachment_path(attachment_id) + .map_err(task_runtime_error)? + })) + } + "attachments.delete" => { + let attachment_id = required_task_string(¶ms, "attachment_id")?; + runtime + .delete_task_attachment(attachment_id) + .map_err(task_runtime_error)?; + Ok(json!({"deleted": true})) + } + "deliveries.create" => { + let project_id = required_task_string(¶ms, "project_id")?; + let item_id = required_task_string(¶ms, "item_id")?; + let input = task_input::(¶ms, "delivery")?; + serialize_task_value( + runtime + .create_delivery(project_id, item_id, input) + .await + .map_err(task_runtime_error)?, + ) + } + "deliveries.add_asset" => { + let delivery_id = required_task_string(¶ms, "delivery_id")?; + let relative_path = required_task_string(¶ms, "relative_path")?; + let input = task_input::(¶ms, "file")?; + serialize_task_value( + runtime + .add_delivery_asset(delivery_id, relative_path, input) + .map_err(task_runtime_error)?, + ) + } + "deliveries.finalize" => { + let item_id = required_task_string(¶ms, "item_id")?; + let delivery_id = required_task_string(¶ms, "delivery_id")?; + serialize_task_value( + runtime + .finalize_delivery(item_id, delivery_id) + .map_err(task_runtime_error)?, + ) + } + "deliveries.discard" => { + let delivery_id = required_task_string(¶ms, "delivery_id")?; + runtime + .discard_delivery(delivery_id) + .map_err(task_runtime_error)?; + Ok(json!({"discarded": true})) + } + "deliveries.list" => { + let item_id = required_task_string(¶ms, "item_id")?; + serialize_task_value( + runtime + .list_deliveries(item_id) + .map_err(task_runtime_error)?, + ) + } + "deliveries.get" => { + let delivery_id = required_task_string(¶ms, "delivery_id")?; + serialize_task_value( + runtime + .delivery_detail(delivery_id) + .map_err(task_runtime_error)?, + ) + } + "deliveries.access_asset" => { + let asset_id = required_task_string(¶ms, "asset_id")?; + Ok(json!({ + "path": runtime + .delivery_asset_path(asset_id) + .map_err(task_runtime_error)? + })) + } + _ => Err(AppIpcError::new( + "unsupported_method", + format!("Unsupported task runtime method: {method}"), + )), + } +} + +fn task_input( + params: &Value, + nested_key: &str, +) -> Result { + serde_json::from_value( + params + .get(nested_key) + .cloned() + .unwrap_or_else(|| params.clone()), + ) + .map_err(|error| AppIpcError::new("bad_request", error.to_string())) +} + +fn required_task_string<'a>(params: &'a Value, key: &str) -> Result<&'a str, AppIpcError> { + params + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| AppIpcError::new("bad_request", format!("{key} is required"))) +} + +fn required_task_i64(params: &Value, key: &str) -> Result { + params + .get(key) + .and_then(Value::as_i64) + .ok_or_else(|| AppIpcError::new("bad_request", format!("{key} is required"))) +} + +fn serialize_task_value(value: impl serde::Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| AppIpcError::new("serialization_failed", error.to_string())) +} + +fn task_runtime_error(error: crate::task_runtime::TaskRuntimeError) -> AppIpcError { + let code = match error { + crate::task_runtime::TaskRuntimeError::ProjectNotFound + | crate::task_runtime::TaskRuntimeError::TaskNotFound => "not_found", + crate::task_runtime::TaskRuntimeError::VersionConflict => "version_conflict", + crate::task_runtime::TaskRuntimeError::UnsupportedProvider(_) => "provider_unavailable", + crate::task_runtime::TaskRuntimeError::ProviderRequest(_) => "provider_request_failed", + crate::task_runtime::TaskRuntimeError::Invalid(_) => "bad_request", + _ => "task_runtime_failed", + }; + AppIpcError::new(code, error.to_string()) +} + #[cfg(windows)] async fn handle_builtin_device_command( command_key: &str, diff --git a/executor/src/runtime_work/handler/turns.rs b/executor/src/runtime_work/handler/turns.rs index d002e5a2b1..c5d355c900 100644 --- a/executor/src/runtime_work/handler/turns.rs +++ b/executor/src/runtime_work/handler/turns.rs @@ -44,6 +44,7 @@ fn hook_user_id(value: &Value) -> Option { impl RuntimeWorkRpcHandler { pub(super) fn spawn_turn(&self, mut turn: SpawnTurnRequest) { self.apply_project_workspace_roots(&mut turn.request); + crate::task_runtime::mcp::ensure_task_mcp_server(&mut turn.request); let SpawnTurnRequest { local_task_id, request, diff --git a/executor/src/task_runtime/content.rs b/executor/src/task_runtime/content.rs new file mode 100644 index 0000000000..0b526a6686 --- /dev/null +++ b/executor/src/task_runtime/content.rs @@ -0,0 +1,799 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +use base64::{engine::general_purpose, Engine as _}; +use rusqlite::{params, OptionalExtension, Row}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use super::{ + store::{now, numeric_id}, + BinaryInput, Delivery, DeliveryAsset, DeliveryCreate, DeliveryDetail, LocalTaskStore, + ProjectFile, TaskAttachment, TaskRuntimeError, +}; + +impl LocalTaskStore { + pub fn list_project_files( + &self, + project_id: &str, + ) -> Result, TaskRuntimeError> { + self.get_project(project_id)?; + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT id, cloud_project_id, path, kind, content_type, size_bytes, sha256, + description, version, created_at, updated_at + FROM loop_items + WHERE resource_type = 'file' AND cloud_project_id = ?1 AND deleted_at IS NULL + ORDER BY path", + )?; + let rows = statement.query_map([project_id], map_project_file)?; + rows.collect::, _>>() + .map_err(TaskRuntimeError::from) + } + + pub fn create_project_folder( + &self, + project_id: &str, + path: &str, + ) -> Result { + let path = normalize_relative_path(path)?; + self.get_project(project_id)?; + self.ensure_parent_folders(project_id, &path)?; + if self.project_file_id(project_id, &path)?.is_some() { + return Err(TaskRuntimeError::Invalid( + "project file path already exists".to_owned(), + )); + } + let id = numeric_id(); + let timestamp = now(); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, path, name, kind, + status, version, created_at, updated_at + ) VALUES (?1, 'file', 'default', ?2, ?3, ?4, 'folder', 'active', 1, ?5, ?5)", + params![id, project_id, path, file_name(&path), timestamp], + )?; + drop(connection); + self.get_project_file(project_id, &id) + } + + pub fn upload_project_file( + &self, + project_id: &str, + path: Option<&str>, + input: BinaryInput, + ) -> Result { + let path = normalize_relative_path(path.unwrap_or(&input.display_name))?; + self.get_project(project_id)?; + self.ensure_parent_folders(project_id, &path)?; + if self.project_file_id(project_id, &path)?.is_some() { + return Err(TaskRuntimeError::Invalid( + "project file path already exists".to_owned(), + )); + } + let bytes = decode_binary(&input)?; + let id = numeric_id(); + let object_key = format!("files/{id}/{}", sanitize_file_name(&input.display_name)); + let object_path = self.resolve_object_key(Path::new(&object_key))?; + write_binary(&object_path, &bytes)?; + let timestamp = now(); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, path, name, kind, + display_name, object_key, content_type, size_bytes, sha256, status, + version, created_at, updated_at + ) VALUES (?1, 'file', 'default', ?2, ?3, ?4, 'file', ?5, ?6, ?7, + ?8, ?9, 'active', 1, ?10, ?10)", + params![ + id, + project_id, + path, + file_name(&path), + input.display_name, + object_key, + input.content_type, + bytes.len() as i64, + sha256_hex(&bytes), + timestamp, + ], + )?; + drop(connection); + self.get_project_file(project_id, &id) + } + + pub fn move_project_file( + &self, + file_id: &str, + path: &str, + version: i64, + ) -> Result { + let path = normalize_relative_path(path)?; + let connection = self.connection()?; + let (project_id, old_path, current_version): (String, String, i64) = connection.query_row( + "SELECT cloud_project_id, path, version FROM loop_items + WHERE id = ?1 AND resource_type = 'file' AND deleted_at IS NULL", + [file_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + if current_version != version { + return Err(TaskRuntimeError::VersionConflict); + } + drop(connection); + self.ensure_parent_folders(&project_id, &path)?; + if self.project_file_id(&project_id, &path)?.is_some() { + return Err(TaskRuntimeError::Invalid( + "project file path already exists".to_owned(), + )); + } + let connection = self.connection()?; + let timestamp = now(); + connection.execute( + "UPDATE loop_items SET path = ?1, name = ?2, version = version + 1, + updated_at = ?3 WHERE id = ?4", + params![path, file_name(&path), timestamp, file_id], + )?; + connection.execute( + "UPDATE loop_items SET path = ?1 || substr(path, ?2), + version = version + 1, updated_at = ?3 + WHERE resource_type = 'file' AND cloud_project_id = ?4 + AND path LIKE ?5 AND deleted_at IS NULL", + params![ + path, + old_path.len() as i64 + 1, + timestamp, + project_id, + format!("{old_path}/%"), + ], + )?; + drop(connection); + self.get_project_file(&project_id, file_id) + } + + pub fn delete_project_file( + &self, + file_id: &str, + recursive: bool, + ) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + let (project_id, path, kind): (String, String, String) = connection.query_row( + "SELECT cloud_project_id, path, kind FROM loop_items + WHERE id = ?1 AND resource_type = 'file' AND deleted_at IS NULL", + [file_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + let descendants: i64 = connection.query_row( + "SELECT COUNT(*) FROM loop_items WHERE resource_type = 'file' + AND cloud_project_id = ?1 AND path LIKE ?2 AND deleted_at IS NULL", + params![project_id, format!("{path}/%")], + |row| row.get(0), + )?; + if kind == "folder" && descendants > 0 && !recursive { + return Err(TaskRuntimeError::Invalid( + "folder contains files".to_owned(), + )); + } + let timestamp = now(); + connection.execute( + "UPDATE loop_items SET deleted_at = ?1, updated_at = ?1 + WHERE resource_type = 'file' AND cloud_project_id = ?2 + AND (id = ?3 OR (?4 AND path LIKE ?5))", + params![ + timestamp, + project_id, + file_id, + recursive, + format!("{path}/%"), + ], + )?; + Ok(()) + } + + pub fn project_file_path(&self, file_id: &str) -> Result { + self.object_path(file_id, "file") + } + + fn project_file_id( + &self, + project_id: &str, + path: &str, + ) -> Result, TaskRuntimeError> { + let connection = self.connection()?; + connection + .query_row( + "SELECT id FROM loop_items WHERE resource_type = 'file' + AND cloud_project_id = ?1 AND path = ?2 AND deleted_at IS NULL", + params![project_id, path], + |row| row.get(0), + ) + .optional() + .map_err(TaskRuntimeError::from) + } + + fn get_project_file( + &self, + project_id: &str, + file_id: &str, + ) -> Result { + let connection = self.connection()?; + connection + .query_row( + "SELECT id, cloud_project_id, path, kind, content_type, size_bytes, sha256, + description, version, created_at, updated_at + FROM loop_items + WHERE id = ?1 AND cloud_project_id = ?2 AND resource_type = 'file' + AND deleted_at IS NULL", + params![file_id, project_id], + map_project_file, + ) + .optional()? + .ok_or(TaskRuntimeError::TaskNotFound) + } + + fn ensure_parent_folders(&self, project_id: &str, path: &str) -> Result<(), TaskRuntimeError> { + let parts = path.split('/').collect::>(); + for index in 1..parts.len() { + let parent_path = parts[..index].join("/"); + if self.project_file_id(project_id, &parent_path)?.is_some() { + continue; + } + let id = numeric_id(); + let timestamp = now(); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, path, name, + kind, status, version, created_at, updated_at + ) VALUES (?1, 'file', 'default', ?2, ?3, ?4, 'folder', 'active', + 1, ?5, ?5)", + params![ + id, + project_id, + parent_path, + file_name(&parent_path), + timestamp + ], + )?; + } + Ok(()) + } +} + +fn map_delivery(row: &Row<'_>) -> rusqlite::Result { + let snapshot = row + .get::<_, Option>(3)? + .and_then(|value| serde_json::from_str(&value).ok()); + Ok(Delivery { + id: row.get(0)?, + loop_item_id: row.get(1)?, + created_by_user_id: 0, + source_task_binding_id: row.get(2)?, + source_task_snapshot: snapshot, + status: row.get(4)?, + created_at: row.get(5)?, + delivered_at: row.get(6)?, + assets: vec![], + }) +} + +fn map_delivery_asset(row: &Row<'_>) -> rusqlite::Result { + Ok(DeliveryAsset { + id: row.get(0)?, + kind: row + .get::<_, Option>(1)? + .unwrap_or_else(|| "file".to_owned()), + display_name: row.get(2)?, + relative_path: row.get(3)?, + content_type: row.get(4)?, + size_bytes: row.get::<_, Option>(5)?.unwrap_or(0), + sha256: row.get::<_, Option>(6)?.unwrap_or_default(), + }) +} + +impl LocalTaskStore { + fn binary_root(&self) -> Result { + let parent = self + .path() + .parent() + .ok_or_else(|| TaskRuntimeError::Invalid("task database path is invalid".to_owned()))?; + let root = parent.join("objects"); + fs::create_dir_all(&root).map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + Ok(root) + } + + fn object_path(&self, item_id: &str, resource_type: &str) -> Result { + let connection = self.connection()?; + let path = connection + .query_row( + "SELECT object_key FROM loop_items + WHERE id = ?1 AND resource_type = ?2 AND deleted_at IS NULL", + params![item_id, resource_type], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten() + .ok_or(TaskRuntimeError::TaskNotFound)?; + self.resolve_object_key(Path::new(&path)) + } + + fn resolve_object_key(&self, object_key: &Path) -> Result { + if object_key.is_absolute() + || object_key + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(TaskRuntimeError::Invalid( + "stored object key must be a safe relative path".to_owned(), + )); + } + Ok(self.binary_root()?.join(object_key)) + } +} + +fn decode_binary(input: &BinaryInput) -> Result, TaskRuntimeError> { + general_purpose::STANDARD + .decode(input.base64.as_bytes()) + .map_err(|error| TaskRuntimeError::Invalid(format!("invalid base64 content: {error}"))) +} + +fn write_binary(path: &Path, bytes: &[u8]) -> Result<(), TaskRuntimeError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + } + fs::write(path, bytes).map_err(|error| TaskRuntimeError::Invalid(error.to_string())) +} + +fn normalize_relative_path(value: &str) -> Result { + let value = value.trim().replace('\\', "/"); + if value.is_empty() { + return Err(TaskRuntimeError::Invalid( + "file path is required".to_owned(), + )); + } + let path = Path::new(&value); + if path.is_absolute() + || path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(TaskRuntimeError::Invalid( + "file path must be a safe relative path".to_owned(), + )); + } + Ok(path + .components() + .filter_map(|part| match part { + Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>() + .join("/")) +} + +fn sanitize_file_name(value: &str) -> String { + Path::new(value) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("file") + .to_owned() +} + +fn file_name(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_owned() +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn map_project_file(row: &Row<'_>) -> rusqlite::Result { + let path: String = row.get(2)?; + Ok(ProjectFile { + id: row.get(0)?, + cloud_project_id: row.get(1)?, + name: file_name(&path), + path, + kind: row.get(3)?, + content_type: row.get(4)?, + size_bytes: row.get::<_, Option>(5)?.unwrap_or(0), + sha256: row.get(6)?, + description: row.get::<_, Option>(7)?.unwrap_or_default(), + created_by_user_id: 0, + updated_by_user_id: 0, + version: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +impl LocalTaskStore { + pub fn add_task_attachment( + &self, + project_id: &str, + item_id: &str, + persisted_task: bool, + input: BinaryInput, + ) -> Result { + self.get_project(project_id)?; + if persisted_task { + self.get_item(item_id, "task")?; + } + let bytes = decode_binary(&input)?; + let id = numeric_id(); + let object_key = format!( + "attachments/{id}/{}", + sanitize_file_name(&input.display_name) + ); + let object_path = self.resolve_object_key(Path::new(&object_key))?; + write_binary(&object_path, &bytes)?; + let timestamp = now(); + let loop_item_id = persisted_task.then_some(item_id); + let metadata = json!({ + "external_item_id": (!persisted_task).then_some(item_id), + }); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, loop_item_id, + display_name, object_key, content_type, size_bytes, sha256, metadata, + status, version, created_at, updated_at + ) VALUES (?1, 'attachment', 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, + ?9, 'active', 1, ?10, ?10)", + params![ + id, + project_id, + loop_item_id, + input.display_name, + object_key, + input.content_type, + bytes.len() as i64, + sha256_hex(&bytes), + metadata.to_string(), + timestamp, + ], + )?; + drop(connection); + self.get_task_attachment(&id) + } + + pub fn list_task_attachments( + &self, + item_id: &str, + ) -> Result, TaskRuntimeError> { + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT id, COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + display_name, content_type, size_bytes, sha256, + created_at + FROM loop_items + WHERE resource_type = 'attachment' + AND (loop_item_id = ?1 OR json_extract(metadata, '$.external_item_id') = ?1) + AND deleted_at IS NULL + ORDER BY created_at DESC", + )?; + let rows = statement.query_map([item_id], map_task_attachment)?; + rows.collect::, _>>() + .map_err(TaskRuntimeError::from) + } + + pub fn task_attachment_path(&self, attachment_id: &str) -> Result { + self.object_path(attachment_id, "attachment") + } + + pub fn delete_task_attachment(&self, attachment_id: &str) -> Result<(), TaskRuntimeError> { + let path = self.task_attachment_path(attachment_id)?; + let connection = self.connection()?; + connection.execute( + "UPDATE loop_items SET deleted_at = ?1, updated_at = ?1 + WHERE id = ?2 AND resource_type = 'attachment'", + params![now(), attachment_id], + )?; + let _ = fs::remove_file(path); + Ok(()) + } + + fn get_task_attachment(&self, attachment_id: &str) -> Result { + let connection = self.connection()?; + connection + .query_row( + "SELECT id, COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + display_name, content_type, size_bytes, sha256, + created_at + FROM loop_items + WHERE id = ?1 AND resource_type = 'attachment' AND deleted_at IS NULL", + [attachment_id], + map_task_attachment, + ) + .optional()? + .ok_or(TaskRuntimeError::TaskNotFound) + } +} + +fn map_task_attachment(row: &Row<'_>) -> rusqlite::Result { + Ok(TaskAttachment { + id: row.get(0)?, + loop_item_id: row.get(1)?, + display_name: row.get(2)?, + content_type: row.get(3)?, + size_bytes: row.get::<_, Option>(4)?.unwrap_or(0), + sha256: row.get::<_, Option>(5)?.unwrap_or_default(), + created_by_user_id: 0, + created_at: row.get(6)?, + }) +} + +impl LocalTaskStore { + pub fn create_delivery( + &self, + project_id: &str, + item_id: &str, + persisted_task: bool, + input: DeliveryCreate, + ) -> Result { + self.get_project(project_id)?; + if persisted_task { + self.get_item(item_id, "task")?; + } + let id = numeric_id(); + let base_key = format!("deliveries/{id}"); + let markdown_key = format!("{base_key}/markdown.md"); + let markdown_path = self.resolve_object_key(Path::new(&markdown_key))?; + write_binary(&markdown_path, input.markdown.as_bytes())?; + let chat_key = input + .chat + .as_ref() + .map(|chat| { + let key = format!("{base_key}/chat.json"); + let path = self.resolve_object_key(Path::new(&key))?; + let bytes = serde_json::to_vec_pretty(chat) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + write_binary(&path, &bytes)?; + Ok::<_, TaskRuntimeError>(key) + }) + .transpose()?; + let source_binding_id = input + .source_task + .as_ref() + .map(|task| self.find_task_binding(&task.device_id, &task.task_id)) + .transpose()? + .map(|binding| binding.id); + let source_snapshot = input + .source_task + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + let timestamp = now(); + let loop_item_id = persisted_task.then_some(item_id); + let metadata = json!({ + "external_item_id": (!persisted_task).then_some(item_id), + }); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, loop_item_id, + source_task_binding_id, source_task_snapshot, markdown_object_key, + chat_object_key, metadata, status, version, created_at, updated_at + ) VALUES (?1, 'delivery', 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, + 'draft', 1, ?9, ?9)", + params![ + id, + project_id, + loop_item_id, + source_binding_id, + source_snapshot.map(|value| value.to_string()), + markdown_key, + chat_key, + metadata.to_string(), + timestamp, + ], + )?; + drop(connection); + self.get_delivery(item_id, &id) + } + + pub fn add_delivery_asset( + &self, + delivery_id: &str, + relative_path: &str, + input: BinaryInput, + ) -> Result { + let (project_id, loop_item_id, external_item_id, status): ( + String, + Option, + Option, + String, + ) = { + let connection = self.connection()?; + connection.query_row( + "SELECT cloud_project_id, loop_item_id, + json_extract(metadata, '$.external_item_id'), status + FROM loop_items + WHERE id = ?1 AND resource_type = 'delivery' AND deleted_at IS NULL", + [delivery_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )? + }; + if status != "draft" { + return Err(TaskRuntimeError::Invalid( + "delivery is already delivered".to_owned(), + )); + } + let relative_path = normalize_relative_path(relative_path)?; + let bytes = decode_binary(&input)?; + let id = numeric_id(); + let object_key = format!("deliveries/{delivery_id}/{relative_path}"); + let object_path = self.resolve_object_key(Path::new(&object_key))?; + write_binary(&object_path, &bytes)?; + let timestamp = now(); + let metadata = json!({"external_item_id": external_item_id}); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, loop_item_id, + delivery_id, kind, display_name, relative_path, object_key, + content_type, size_bytes, sha256, metadata, status, version, created_at, updated_at + ) VALUES (?1, 'delivery_asset', 'default', ?2, ?3, ?4, 'file', ?5, ?6, + ?7, ?8, ?9, ?10, ?11, 'draft', 1, ?12, ?12)", + params![ + id, + project_id, + loop_item_id, + delivery_id, + input.display_name, + relative_path, + object_key, + input.content_type, + bytes.len() as i64, + sha256_hex(&bytes), + metadata.to_string(), + timestamp, + ], + )?; + drop(connection); + self.get_delivery_asset(&id) + } + + pub fn finalize_delivery( + &self, + item_id: &str, + delivery_id: &str, + ) -> Result { + self.get_delivery(item_id, delivery_id)?; + let timestamp = now(); + let connection = self.connection()?; + connection.execute( + "UPDATE loop_items SET status = 'delivered', delivered_at = ?1, updated_at = ?1 + WHERE id = ?2 AND resource_type = 'delivery'", + params![timestamp, delivery_id], + )?; + connection.execute( + "UPDATE loop_items SET status = 'delivered', updated_at = ?1 + WHERE delivery_id = ?2 AND resource_type = 'delivery_asset'", + params![timestamp, delivery_id], + )?; + connection.execute( + "UPDATE loop_items SET current_delivery_id = ?1, updated_at = ?2 WHERE id = ?3", + params![delivery_id, timestamp, item_id], + )?; + drop(connection); + self.get_delivery(item_id, delivery_id) + } + + pub fn discard_delivery(&self, delivery_id: &str) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + connection.execute( + "UPDATE loop_items SET deleted_at = ?1, updated_at = ?1 + WHERE (id = ?2 AND resource_type = 'delivery') + OR (delivery_id = ?2 AND resource_type = 'delivery_asset')", + params![now(), delivery_id], + )?; + let _ = fs::remove_dir_all(self.binary_root()?.join("deliveries").join(delivery_id)); + Ok(()) + } + + pub fn list_deliveries(&self, item_id: &str) -> Result, TaskRuntimeError> { + let connection = self.connection()?; + let ids = { + let mut statement = connection.prepare( + "SELECT id FROM loop_items + WHERE resource_type = 'delivery' + AND (loop_item_id = ?1 OR json_extract(metadata, '$.external_item_id') = ?1) + AND deleted_at IS NULL ORDER BY created_at DESC", + )?; + let ids = statement + .query_map([item_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids + }; + drop(connection); + ids.into_iter() + .map(|id| self.get_delivery(item_id, &id)) + .collect() + } + + pub fn delivery_detail(&self, delivery_id: &str) -> Result { + let (item_id, markdown_path, chat_path): (String, String, Option) = { + let connection = self.connection()?; + connection.query_row( + "SELECT COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + markdown_object_key, chat_object_key + FROM loop_items WHERE id = ?1 AND resource_type = 'delivery' + AND deleted_at IS NULL", + [delivery_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )? + }; + let delivery = self.get_delivery(&item_id, delivery_id)?; + let markdown = fs::read_to_string(self.resolve_object_key(Path::new(&markdown_path))?) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + let chat = chat_path + .map(|path| self.resolve_object_key(Path::new(&path))) + .transpose()? + .map(fs::read_to_string) + .transpose() + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))? + .map(|content| serde_json::from_str(&content)) + .transpose() + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + Ok(DeliveryDetail { + delivery, + markdown, + chat, + }) + } + + pub fn delivery_asset_path(&self, asset_id: &str) -> Result { + self.object_path(asset_id, "delivery_asset") + } + + fn get_delivery(&self, item_id: &str, delivery_id: &str) -> Result { + let connection = self.connection()?; + let mut delivery = connection + .query_row( + "SELECT id, + COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + source_task_binding_id, source_task_snapshot, + status, created_at, delivered_at + FROM loop_items + WHERE id = ?1 AND resource_type = 'delivery' + AND (loop_item_id = ?2 OR json_extract(metadata, '$.external_item_id') = ?2) + AND deleted_at IS NULL", + params![delivery_id, item_id], + map_delivery, + ) + .optional()? + .ok_or(TaskRuntimeError::TaskNotFound)?; + let mut statement = connection.prepare( + "SELECT id, kind, display_name, relative_path, content_type, size_bytes, sha256 + FROM loop_items WHERE resource_type = 'delivery_asset' AND delivery_id = ?1 + AND deleted_at IS NULL ORDER BY relative_path", + )?; + delivery.assets = statement + .query_map([delivery_id], map_delivery_asset)? + .collect::, _>>()?; + Ok(delivery) + } + + fn get_delivery_asset(&self, asset_id: &str) -> Result { + let connection = self.connection()?; + connection + .query_row( + "SELECT id, kind, display_name, relative_path, content_type, size_bytes, sha256 + FROM loop_items WHERE id = ?1 AND resource_type = 'delivery_asset' + AND deleted_at IS NULL", + [asset_id], + map_delivery_asset, + ) + .optional()? + .ok_or(TaskRuntimeError::TaskNotFound) + } +} diff --git a/executor/src/task_runtime/credentials.rs b/executor/src/task_runtime/credentials.rs new file mode 100644 index 0000000000..9bcc6be501 --- /dev/null +++ b/executor/src/task_runtime/credentials.rs @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng, Payload}, + Aes256Gcm, Key, Nonce, +}; +use base64::{engine::general_purpose, Engine as _}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::{TaskProviderKind, TaskRuntimeError}; + +const CREDENTIAL_VERSION: i64 = 1; +const CREDENTIAL_ALGORITHM: &str = "aes-256-gcm"; +const MASTER_KEY_FILE: &str = "provider-master-key-v1"; +const TOKEN_INPUT_KEY: &str = "token"; +const CREDENTIAL_KEY: &str = "credential"; + +pub(crate) fn encrypt_provider_config( + database_path: &Path, + provider: TaskProviderKind, + provider_config: Value, +) -> Result { + let mut config = provider_config + .as_object() + .cloned() + .ok_or_else(|| invalid("provider_config must be an object"))?; + if config.remove(CREDENTIAL_KEY).is_some() { + return Err(invalid( + "encrypted provider credentials cannot be supplied by project input", + )); + } + let token = match config.remove(TOKEN_INPUT_KEY) { + Some(Value::String(value)) => { + let value = value.trim().to_owned(); + (!value.is_empty() && value != "***").then_some(value) + } + Some(Value::Null) | None => None, + Some(_) => return Err(invalid("provider token must be a string")), + }; + if let Some(token) = token { + encrypt_token(database_path, provider, &mut config, &token)?; + } + Ok(Value::Object(config)) +} + +pub(crate) fn update_provider_config( + database_path: &Path, + provider: TaskProviderKind, + current: &Value, + replacement: Value, +) -> Result { + let mut config = replacement + .as_object() + .cloned() + .ok_or_else(|| invalid("provider_config must be an object"))?; + if config.remove(CREDENTIAL_KEY).is_some() { + return Err(invalid( + "encrypted provider credentials cannot be supplied by project input", + )); + } + config.remove("credential_configured"); + let token = match config.remove(TOKEN_INPUT_KEY) { + Some(Value::String(value)) => { + let value = value.trim().to_owned(); + (!value.is_empty() && value != "***").then_some(value) + } + Some(Value::Null) => return Ok(Value::Object(config)), + Some(_) => return Err(invalid("provider token must be a string")), + None => { + preserve_credential(provider, current, &mut config)?; + return Ok(Value::Object(config)); + } + }; + if let Some(token) = token { + encrypt_token(database_path, provider, &mut config, &token)?; + } + Ok(Value::Object(config)) +} + +pub(crate) fn decrypt_provider_credential( + database_path: &Path, + provider: TaskProviderKind, + provider_config: &Map, +) -> Result, TaskRuntimeError> { + let Some(credential) = provider_config + .get(CREDENTIAL_KEY) + .and_then(Value::as_object) + else { + return Ok(None); + }; + require_credential_field(credential, "algorithm", CREDENTIAL_ALGORITHM)?; + let version = credential + .get("version") + .and_then(Value::as_i64) + .ok_or_else(|| invalid("provider credential version is required"))?; + if version != CREDENTIAL_VERSION { + return Err(invalid("unsupported provider credential version")); + } + let key = load_master_key(database_path)?; + require_credential_field(credential, "key_id", &key_id(&key))?; + let nonce = decode_credential_bytes(credential, "nonce")?; + if nonce.len() != 12 { + return Err(invalid("provider credential nonce is invalid")); + } + let ciphertext = decode_credential_bytes(credential, "ciphertext")?; + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let context = credential_context(provider, provider_config)?; + let plaintext = cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &ciphertext, + aad: context.as_bytes(), + }, + ) + .map_err(|_| invalid("provider credential decryption failed"))?; + String::from_utf8(plaintext) + .map(Some) + .map_err(|_| invalid("provider credential is not UTF-8")) +} + +pub(crate) fn mask_provider_config(provider_config: &mut Value) { + let Some(config) = provider_config.as_object_mut() else { + return; + }; + let configured = config + .get(CREDENTIAL_KEY) + .and_then(Value::as_object) + .is_some(); + config.remove(TOKEN_INPUT_KEY); + config.remove(CREDENTIAL_KEY); + config.insert("credential_configured".to_owned(), json!(configured)); +} + +fn encrypt_token( + database_path: &Path, + provider: TaskProviderKind, + config: &mut Map, + token: &str, +) -> Result<(), TaskRuntimeError> { + let key = load_or_create_master_key(database_path)?; + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let context = credential_context(provider, config)?; + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: token.as_bytes(), + aad: context.as_bytes(), + }, + ) + .map_err(|_| invalid("provider credential encryption failed"))?; + config.insert( + CREDENTIAL_KEY.to_owned(), + json!({ + "version": CREDENTIAL_VERSION, + "algorithm": CREDENTIAL_ALGORITHM, + "key_id": key_id(&key), + "nonce": general_purpose::STANDARD.encode(nonce), + "ciphertext": general_purpose::STANDARD.encode(ciphertext), + }), + ); + Ok(()) +} + +fn preserve_credential( + provider: TaskProviderKind, + current: &Value, + replacement: &mut Map, +) -> Result<(), TaskRuntimeError> { + let Some(current) = current.as_object() else { + return Ok(()); + }; + let Some(credential) = current.get(CREDENTIAL_KEY) else { + return Ok(()); + }; + if credential_context(provider, current)? != credential_context(provider, replacement)? { + return Err(invalid( + "provider token is required when repository or domain changes", + )); + } + replacement.insert(CREDENTIAL_KEY.to_owned(), credential.clone()); + Ok(()) +} + +fn credential_context( + provider: TaskProviderKind, + provider_config: &Map, +) -> Result { + let repository = required_config_string(provider_config, "repository")?; + let domain = provider_config + .get("domain") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(match provider { + TaskProviderKind::Github => "github.com", + TaskProviderKind::Gitlab => "gitlab.com", + _ => return Err(invalid("provider credentials require GitHub or GitLab")), + }); + Ok(format!("{provider:?}:{domain}:{repository}")) +} + +fn required_config_string<'a>( + provider_config: &'a Map, + key: &str, +) -> Result<&'a str, TaskRuntimeError> { + provider_config + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| invalid(&format!("provider_config.{key} is required"))) +} + +fn master_key_path(database_path: &Path) -> Result { + let data_directory = database_path + .parent() + .ok_or_else(|| invalid("task database path is invalid"))?; + let executor_home = data_directory + .parent() + .ok_or_else(|| invalid("Executor home path is invalid"))?; + Ok(executor_home.join("credentials").join(MASTER_KEY_FILE)) +} + +fn load_or_create_master_key(database_path: &Path) -> Result<[u8; 32], TaskRuntimeError> { + let path = master_key_path(database_path)?; + if path.exists() { + return read_master_key(&path); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(storage_error)?; + } + let key = Aes256Gcm::generate_key(&mut OsRng); + if !write_new_master_key(&path, key.as_slice())? { + return read_master_key(&path); + } + let mut value = [0_u8; 32]; + value.copy_from_slice(key.as_slice()); + Ok(value) +} + +fn load_master_key(database_path: &Path) -> Result<[u8; 32], TaskRuntimeError> { + let path = master_key_path(database_path)?; + read_master_key(&path) +} + +fn read_master_key(path: &Path) -> Result<[u8; 32], TaskRuntimeError> { + let encoded = fs::read_to_string(path).map_err(storage_error)?; + let bytes = general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|_| invalid("provider master key is invalid"))?; + if bytes.len() != 32 { + return Err(invalid("provider master key must contain 32 bytes")); + } + let mut key = [0_u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +fn write_new_master_key(path: &Path, key: &[u8]) -> Result { + let temporary = path.with_extension(format!("tmp-{}", Uuid::new_v4())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary).map_err(storage_error)?; + file.write_all(general_purpose::STANDARD.encode(key).as_bytes()) + .map_err(storage_error)?; + file.sync_all().map_err(storage_error)?; + drop(file); + let linked = match fs::hard_link(&temporary, path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => false, + Err(error) => { + let _ = fs::remove_file(&temporary); + return Err(storage_error(error)); + } + }; + fs::remove_file(&temporary).map_err(storage_error)?; + Ok(linked) +} + +fn key_id(key: &[u8]) -> String { + let digest = format!("{:x}", Sha256::digest(key)); + format!("local-{}", &digest[..16]) +} + +fn require_credential_field( + credential: &Map, + key: &str, + expected: &str, +) -> Result<(), TaskRuntimeError> { + let value = credential + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| invalid(&format!("provider credential {key} is required")))?; + if value != expected { + return Err(invalid(&format!( + "provider credential {key} does not match" + ))); + } + Ok(()) +} + +fn decode_credential_bytes( + credential: &Map, + key: &str, +) -> Result, TaskRuntimeError> { + let value = credential + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| invalid(&format!("provider credential {key} is required")))?; + general_purpose::STANDARD + .decode(value) + .map_err(|_| invalid(&format!("provider credential {key} is invalid"))) +} + +fn invalid(message: &str) -> TaskRuntimeError { + TaskRuntimeError::Invalid(message.to_owned()) +} + +fn storage_error(error: std::io::Error) -> TaskRuntimeError { + TaskRuntimeError::Invalid(format!("provider credential storage failed: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn database_path(directory: &tempfile::TempDir) -> PathBuf { + directory.path().join("data").join("tasks.sqlite") + } + + #[test] + fn encrypts_provider_tokens_and_masks_public_config() { + let directory = tempfile::tempdir().unwrap(); + let database_path = database_path(&directory); + let encrypted = encrypt_provider_config( + &database_path, + TaskProviderKind::Github, + json!({ + "repository": "acme/repo", + "domain": "github.com", + "token": "github-secret" + }), + ) + .unwrap(); + + let serialized = encrypted.to_string(); + assert!(!serialized.contains("github-secret")); + assert_eq!( + decrypt_provider_credential( + &database_path, + TaskProviderKind::Github, + encrypted.as_object().unwrap(), + ) + .unwrap() + .as_deref(), + Some("github-secret") + ); + let credential = &encrypted["credential"]; + assert_eq!(credential["algorithm"], CREDENTIAL_ALGORITHM); + assert_eq!(credential["version"], CREDENTIAL_VERSION); + assert!(credential["nonce"].as_str().unwrap().len() >= 16); + assert!(credential["ciphertext"].as_str().unwrap().len() >= 16); + + let mut masked = encrypted; + mask_provider_config(&mut masked); + assert_eq!(masked["credential_configured"], true); + assert!(masked.get("credential").is_none()); + assert!(masked.get("token").is_none()); + } + + #[test] + fn binds_ciphertext_to_provider_domain_and_repository() { + let directory = tempfile::tempdir().unwrap(); + let database_path = database_path(&directory); + let mut encrypted = encrypt_provider_config( + &database_path, + TaskProviderKind::Gitlab, + json!({ + "repository": "group/project", + "domain": "gitlab.example.com", + "token": "gitlab-secret" + }), + ) + .unwrap(); + encrypted["repository"] = json!("other/project"); + + let error = decrypt_provider_credential( + &database_path, + TaskProviderKind::Gitlab, + encrypted.as_object().unwrap(), + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("provider credential decryption failed")); + } + + #[test] + fn refuses_client_supplied_ciphertext() { + let directory = tempfile::tempdir().unwrap(); + let error = encrypt_provider_config( + &database_path(&directory), + TaskProviderKind::Github, + json!({ + "repository": "acme/repo", + "credential": { + "version": 1, + "algorithm": "aes-256-gcm", + "ciphertext": "untrusted" + } + }), + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("cannot be supplied by project input")); + } + + #[test] + fn rotates_preserves_and_clears_provider_tokens() { + let directory = tempfile::tempdir().unwrap(); + let database_path = database_path(&directory); + let encrypted = encrypt_provider_config( + &database_path, + TaskProviderKind::Github, + json!({ + "repository": "acme/repo", + "domain": "github.com", + "token": "first-secret" + }), + ) + .unwrap(); + + let preserved = update_provider_config( + &database_path, + TaskProviderKind::Github, + &encrypted, + json!({ + "repository": "acme/repo", + "domain": "github.com", + "credential_configured": true + }), + ) + .unwrap(); + assert_eq!( + decrypt_provider_credential( + &database_path, + TaskProviderKind::Github, + preserved.as_object().unwrap(), + ) + .unwrap() + .as_deref(), + Some("first-secret") + ); + + let rotated = update_provider_config( + &database_path, + TaskProviderKind::Github, + &preserved, + json!({ + "repository": "acme/repo", + "domain": "github.com", + "token": "second-secret" + }), + ) + .unwrap(); + assert!(!rotated.to_string().contains("second-secret")); + assert_eq!( + decrypt_provider_credential( + &database_path, + TaskProviderKind::Github, + rotated.as_object().unwrap(), + ) + .unwrap() + .as_deref(), + Some("second-secret") + ); + + let cleared = update_provider_config( + &database_path, + TaskProviderKind::Github, + &rotated, + json!({ + "repository": "acme/repo", + "domain": "github.com", + "token": null + }), + ) + .unwrap(); + assert_eq!( + decrypt_provider_credential( + &database_path, + TaskProviderKind::Github, + cleared.as_object().unwrap(), + ) + .unwrap(), + None + ); + } + + #[cfg(unix)] + #[test] + fn creates_master_key_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let database_path = database_path(&directory); + encrypt_provider_config( + &database_path, + TaskProviderKind::Github, + json!({"repository": "acme/repo", "token": "secret"}), + ) + .unwrap(); + + let mode = fs::metadata(master_key_path(&database_path).unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/executor/src/task_runtime/issue_provider.rs b/executor/src/task_runtime/issue_provider.rs new file mode 100644 index 0000000000..20d91765c7 --- /dev/null +++ b/executor/src/task_runtime/issue_provider.rs @@ -0,0 +1,1084 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use reqwest::{Client, RequestBuilder}; +use serde::Deserialize; +use serde_json::{json, Value}; +use url::{form_urlencoded::byte_serialize, Url}; + +use crate::logging::{format_executor_log, write_executor_log_line}; + +use super::{ + credentials::decrypt_provider_credential, IssueComment, LoopItem, TaskCreate, TaskProviderKind, + TaskRuntimeError, TaskUpdate, +}; + +const PARENT_MARKER: &str = "Wegent-Parent:"; +const PRIORITY_LABEL_PREFIX: &str = "wegent:priority:"; +const STATUS_LABEL_PREFIX: &str = "wegent:status:"; +const PAGE_SIZE: usize = 100; +const MAX_PAGES: usize = 100; + +#[derive(Clone)] +pub(crate) struct IssueProvider { + client: Client, + database_path: PathBuf, +} + +impl IssueProvider { + pub(crate) fn new(database_path: PathBuf) -> Result { + let client = Client::builder() + .user_agent(concat!("wegent-executor/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(provider_error)?; + Ok(Self { + client, + database_path, + }) + } + + pub(crate) async fn list( + &self, + project: &LoopItem, + provider: TaskProviderKind, + ) -> Result, TaskRuntimeError> { + let config = self.provider_config(project, provider)?; + match provider { + TaskProviderKind::Github => self.list_github(project, &config).await, + TaskProviderKind::Gitlab => self.list_gitlab(project, &config).await, + _ => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub(crate) async fn get( + &self, + project: &LoopItem, + provider: TaskProviderKind, + task_id: &str, + ) -> Result { + let config = self.provider_config(project, provider)?; + let number = issue_number(project, task_id)?; + match provider { + TaskProviderKind::Github => self.get_github(project, &config, number).await, + TaskProviderKind::Gitlab => self.get_gitlab(project, &config, number).await, + _ => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub(crate) async fn create( + &self, + project: &LoopItem, + provider: TaskProviderKind, + input: TaskCreate, + ) -> Result { + validate_external_title(&input.title)?; + validate_external_status(&input.status)?; + let config = self.provider_config(project, provider)?; + config.require_write_token()?; + let description = with_parent_marker( + &input.description, + input + .parent_id + .as_deref() + .map(|parent_id| issue_number(project, parent_id)) + .transpose()?, + ); + match provider { + TaskProviderKind::Github => { + let url = format!("{}/repos/{}/issues", config.api_base, config.repository); + let issue = self + .send( + provider, + "create", + &config.repository, + config + .authorize(self.client.post(url), provider) + .json(&json!({ + "title": input.title, + "body": description, + "labels": labels_for_write( + input.tags, + &input.priority, + &input.status, + ), + })), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + let item = github_loop_item(project, issue); + if input.status == "completed" { + return self + .update( + project, + provider, + &item.id, + TaskUpdate { + version: item.version, + status: Some(input.status), + ..TaskUpdate::default() + }, + ) + .await; + } + Ok(item) + } + TaskProviderKind::Gitlab => { + let repository = encode_path_segment(&config.repository); + let url = format!("{}/projects/{repository}/issues", config.api_base); + let issue = self + .send( + provider, + "create", + &config.repository, + config + .authorize(self.client.post(url), provider) + .json(&json!({ + "title": input.title, + "description": description, + "labels": labels_for_write( + input.tags, + &input.priority, + &input.status, + ).join(","), + })), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + let item = gitlab_loop_item(project, issue); + if input.status == "completed" { + return self + .update( + project, + provider, + &item.id, + TaskUpdate { + version: item.version, + status: Some(input.status), + ..TaskUpdate::default() + }, + ) + .await; + } + Ok(item) + } + _ => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub(crate) async fn update( + &self, + project: &LoopItem, + provider: TaskProviderKind, + task_id: &str, + input: TaskUpdate, + ) -> Result { + if let Some(priority) = input.priority.as_deref() { + validate_external_priority(priority)?; + } + if let Some(title) = input.title.as_deref() { + validate_external_title(title)?; + } + if let Some(status) = input.status.as_deref() { + validate_external_status(status)?; + } + let config = self.provider_config(project, provider)?; + config.require_write_token()?; + let number = issue_number(project, task_id)?; + let updates_labels = + input.tags.is_some() || input.priority.is_some() || input.status.is_some(); + let needs_current = (input.description.is_none() && input.parent_id.is_some()) + || (updates_labels + && (input.tags.is_none() || input.priority.is_none() || input.status.is_none())); + let current = if needs_current { + Some(self.get(project, provider, task_id).await?) + } else { + None + }; + let labels = if updates_labels { + let tags = input.tags.clone().unwrap_or_else(|| { + current + .as_ref() + .and_then(|item| item.metadata.get("labels")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() + }); + let priority = input + .priority + .as_deref() + .or_else(|| current.as_ref().and_then(|item| item.priority.as_deref())) + .unwrap_or("none"); + let status = input + .status + .as_deref() + .or_else(|| current.as_ref().and_then(|item| item.status.as_deref())) + .unwrap_or("pending"); + Some(labels_for_write(tags, priority, status)) + } else { + None + }; + let description = match (input.description, input.parent_id) { + (Some(description), parent_id) => Some(with_parent_marker( + &description, + parent_id + .flatten() + .as_deref() + .map(|parent_id| issue_number(project, parent_id)) + .transpose()?, + )), + (None, Some(parent_id)) => Some(with_parent_marker( + current + .as_ref() + .map(|item| item.description.as_str()) + .unwrap_or_default(), + parent_id + .as_deref() + .map(|parent_id| issue_number(project, parent_id)) + .transpose()?, + )), + (None, None) => None, + }; + match provider { + TaskProviderKind::Github => { + let url = format!( + "{}/repos/{}/issues/{number}", + config.api_base, config.repository + ); + let mut body = serde_json::Map::new(); + insert_optional(&mut body, "title", input.title); + insert_optional(&mut body, "body", description); + insert_optional(&mut body, "labels", labels); + if let Some(status) = input.status { + body.insert( + "state".to_owned(), + json!(if status == "completed" { + "closed" + } else { + "open" + }), + ); + } + let issue = self + .send( + provider, + "update", + &config.repository, + config + .authorize(self.client.patch(url), provider) + .json(&body), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + Ok(github_loop_item(project, issue)) + } + TaskProviderKind::Gitlab => { + let repository = encode_path_segment(&config.repository); + let url = format!("{}/projects/{repository}/issues/{number}", config.api_base); + let mut body = serde_json::Map::new(); + insert_optional(&mut body, "title", input.title); + insert_optional(&mut body, "description", description); + if let Some(labels) = labels { + body.insert("labels".to_owned(), json!(labels.join(","))); + } + if let Some(status) = input.status { + body.insert( + "state_event".to_owned(), + json!(if status == "completed" { + "close" + } else { + "reopen" + }), + ); + } + let issue = self + .send( + provider, + "update", + &config.repository, + config.authorize(self.client.put(url), provider).json(&body), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + Ok(gitlab_loop_item(project, issue)) + } + _ => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub(crate) async fn add_comment( + &self, + project: &LoopItem, + provider: TaskProviderKind, + task_id: &str, + body: &str, + ) -> Result { + if body.trim().is_empty() { + return Err(invalid("comment body is required")); + } + let config = self.provider_config(project, provider)?; + config.require_write_token()?; + let number = issue_number(project, task_id)?; + match provider { + TaskProviderKind::Github => { + let url = format!( + "{}/repos/{}/issues/{number}/comments", + config.api_base, config.repository + ); + let comment = self + .send( + provider, + "comment", + &config.repository, + config + .authorize(self.client.post(url), provider) + .json(&json!({"body": body.trim()})), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + Ok(IssueComment { + id: comment.id.to_string(), + body: comment.body, + author: comment.user.login, + web_url: Some(comment.html_url), + created_at: comment.created_at, + updated_at: comment.updated_at, + }) + } + TaskProviderKind::Gitlab => { + let repository = encode_path_segment(&config.repository); + let url = format!( + "{}/projects/{repository}/issues/{number}/notes", + config.api_base + ); + let comment = self + .send( + provider, + "comment", + &config.repository, + config + .authorize(self.client.post(url), provider) + .json(&json!({"body": body.trim()})), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + Ok(IssueComment { + id: comment.id.to_string(), + body: comment.body, + author: comment.author.username, + web_url: None, + created_at: comment.created_at, + updated_at: comment.updated_at, + }) + } + _ => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + async fn list_github( + &self, + project: &LoopItem, + config: &ProviderConfig, + ) -> Result, TaskRuntimeError> { + let mut issues = Vec::new(); + for page in 1..=MAX_PAGES { + let url = format!( + "{}/repos/{}/issues?state=all&per_page={PAGE_SIZE}&page={page}", + config.api_base, config.repository + ); + let mut batch = self + .send( + TaskProviderKind::Github, + "list", + &config.repository, + config.authorize(self.client.get(url), TaskProviderKind::Github), + ) + .await? + .json::>() + .await + .map_err(provider_error)?; + let complete = batch.len() < PAGE_SIZE; + issues.append(&mut batch); + if complete { + break; + } + } + Ok(issues + .into_iter() + .filter(|issue| issue.pull_request.is_none()) + .map(|issue| github_loop_item(project, issue)) + .collect()) + } + + fn provider_config( + &self, + project: &LoopItem, + provider: TaskProviderKind, + ) -> Result { + ProviderConfig::from_project(project, provider, &self.database_path) + } + + async fn get_github( + &self, + project: &LoopItem, + config: &ProviderConfig, + number: i64, + ) -> Result { + let url = format!( + "{}/repos/{}/issues/{number}", + config.api_base, config.repository + ); + let issue = self + .send( + TaskProviderKind::Github, + "get", + &config.repository, + config.authorize(self.client.get(url), TaskProviderKind::Github), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + if issue.pull_request.is_some() { + return Err(TaskRuntimeError::TaskNotFound); + } + Ok(github_loop_item(project, issue)) + } + + async fn list_gitlab( + &self, + project: &LoopItem, + config: &ProviderConfig, + ) -> Result, TaskRuntimeError> { + let repository = encode_path_segment(&config.repository); + let mut issues = Vec::new(); + for page in 1..=MAX_PAGES { + let url = format!( + "{}/projects/{repository}/issues?scope=all&per_page={PAGE_SIZE}&page={page}", + config.api_base + ); + let mut batch = self + .send( + TaskProviderKind::Gitlab, + "list", + &config.repository, + config.authorize(self.client.get(url), TaskProviderKind::Gitlab), + ) + .await? + .json::>() + .await + .map_err(provider_error)?; + let complete = batch.len() < PAGE_SIZE; + issues.append(&mut batch); + if complete { + break; + } + } + Ok(issues + .into_iter() + .map(|issue| gitlab_loop_item(project, issue)) + .collect()) + } + + async fn get_gitlab( + &self, + project: &LoopItem, + config: &ProviderConfig, + number: i64, + ) -> Result { + let repository = encode_path_segment(&config.repository); + let url = format!("{}/projects/{repository}/issues/{number}", config.api_base); + let issue = self + .send( + TaskProviderKind::Gitlab, + "get", + &config.repository, + config.authorize(self.client.get(url), TaskProviderKind::Gitlab), + ) + .await? + .json::() + .await + .map_err(provider_error)?; + Ok(gitlab_loop_item(project, issue)) + } + + async fn send( + &self, + provider: TaskProviderKind, + operation: &str, + repository: &str, + request: RequestBuilder, + ) -> Result { + let request = request.build().map_err(provider_error)?; + let method = request.method().to_string(); + let endpoint = request.url().to_string(); + let fields = [ + ("provider", provider_key(provider).to_owned()), + ("operation", operation.to_owned()), + ("repository", repository.to_owned()), + ("method", method), + ("endpoint", endpoint), + ]; + write_executor_log_line(&format_executor_log( + "external issue request started", + &fields, + )); + let response = match self.client.execute(request).await { + Ok(response) => response, + Err(error) => { + write_executor_log_line(&format_executor_log( + "external issue request failed", + &[ + ("provider", provider_key(provider).to_owned()), + ("operation", operation.to_owned()), + ("repository", repository.to_owned()), + ("error", error.to_string()), + ], + )); + return Err(provider_error(error)); + } + }; + write_executor_log_line(&format_executor_log( + "external issue request finished", + &[ + ("provider", provider_key(provider).to_owned()), + ("operation", operation.to_owned()), + ("repository", repository.to_owned()), + ("status", response.status().as_u16().to_string()), + ], + )); + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(TaskRuntimeError::TaskNotFound); + } + response.error_for_status().map_err(provider_error) + } +} + +fn provider_key(provider: TaskProviderKind) -> &'static str { + match provider { + TaskProviderKind::Github => "github", + TaskProviderKind::Gitlab => "gitlab", + TaskProviderKind::Local => "local", + TaskProviderKind::Backend => "backend", + } +} + +fn normalize_repository( + repository: &str, + provider: TaskProviderKind, +) -> Result { + let normalized = repository.trim().trim_matches('/'); + let normalized = if provider == TaskProviderKind::Gitlab { + normalized.split("/-/").next().unwrap_or_default() + } else { + normalized + }; + let normalized = normalized.trim_end_matches(".git"); + if normalized.is_empty() { + return Err(invalid("provider_config.repository is required")); + } + Ok(normalized.to_owned()) +} + +struct ProviderConfig { + repository: String, + api_base: String, + token: Option, +} + +impl ProviderConfig { + fn from_project( + project: &LoopItem, + provider: TaskProviderKind, + database_path: &std::path::Path, + ) -> Result { + let value = project + .metadata + .get("provider_config") + .and_then(Value::as_object) + .ok_or_else(|| invalid("provider_config must be an object"))?; + let repository = value + .get("repository") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| invalid("provider_config.repository is required"))?; + let repository = normalize_repository(repository, provider)?; + let (default_domain, default_api_base) = match provider { + TaskProviderKind::Github => ("github.com", "https://api.github.com"), + TaskProviderKind::Gitlab => ("gitlab.com", "https://gitlab.com/api/v4"), + _ => { + return Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))) + } + }; + let domain = string_config(value, "domain").unwrap_or_else(|| default_domain.to_owned()); + let api_base = string_config(value, "api_base") + .unwrap_or_else(|| default_api_base.to_owned()) + .trim_end_matches('/') + .to_owned(); + validate_provider_endpoint(provider, &domain, &api_base)?; + if provider == TaskProviderKind::Github { + validate_github_repository(&repository)?; + } + let token = decrypt_provider_credential(database_path, provider, value)?; + Ok(Self { + repository, + api_base, + token, + }) + } + + fn authorize(&self, request: RequestBuilder, provider: TaskProviderKind) -> RequestBuilder { + let Some(token) = self.token.as_deref() else { + return request; + }; + match provider { + TaskProviderKind::Github => request + .header("Authorization", format!("Bearer {token}")) + .header("X-GitHub-Api-Version", "2022-11-28"), + TaskProviderKind::Gitlab => request.header("PRIVATE-TOKEN", token), + _ => request, + } + } + + fn require_write_token(&self) -> Result<(), TaskRuntimeError> { + self.token + .as_ref() + .filter(|token| !token.trim().is_empty()) + .map(|_| ()) + .ok_or_else(|| invalid("provider credentials are required for write operations")) + } +} + +#[derive(Deserialize)] +struct GithubIssue { + number: i64, + title: String, + body: Option, + state: String, + html_url: String, + user: ProviderUser, + labels: Vec, + comments: i64, + created_at: String, + updated_at: String, + closed_at: Option, + pull_request: Option, +} + +#[derive(Deserialize)] +struct GithubLabel { + name: String, +} + +#[derive(Deserialize)] +struct ProviderUser { + login: String, +} + +#[derive(Deserialize)] +struct GithubComment { + id: i64, + body: String, + html_url: String, + user: ProviderUser, + created_at: String, + updated_at: String, +} + +#[derive(Deserialize)] +struct GitlabIssue { + iid: i64, + title: String, + description: Option, + state: String, + web_url: String, + author: GitlabUser, + labels: Vec, + user_notes_count: i64, + created_at: String, + updated_at: String, + closed_at: Option, +} + +#[derive(Deserialize)] +struct GitlabUser { + username: String, +} + +#[derive(Deserialize)] +struct GitlabComment { + id: i64, + body: String, + author: GitlabUser, + created_at: String, + updated_at: String, +} + +fn github_loop_item(project: &LoopItem, issue: GithubIssue) -> LoopItem { + issue_loop_item( + project, + TaskProviderKind::Github, + issue.number, + issue.title, + issue.body.unwrap_or_default(), + issue.state, + issue.html_url, + issue.user.login, + issue.labels.into_iter().map(|label| label.name).collect(), + issue.comments, + issue.created_at, + issue.updated_at, + issue.closed_at, + ) +} + +fn gitlab_loop_item(project: &LoopItem, issue: GitlabIssue) -> LoopItem { + issue_loop_item( + project, + TaskProviderKind::Gitlab, + issue.iid, + issue.title, + issue.description.unwrap_or_default(), + issue.state, + issue.web_url, + issue.author.username, + issue.labels, + issue.user_notes_count, + issue.created_at, + issue.updated_at, + issue.closed_at, + ) +} + +#[allow(clippy::too_many_arguments)] +fn issue_loop_item( + project: &LoopItem, + provider: TaskProviderKind, + number: i64, + title: String, + description: String, + provider_state: String, + web_url: String, + author: String, + labels: Vec, + comments: i64, + created_at: String, + updated_at: String, + closed_at: Option, +) -> LoopItem { + let id = issue_id(project, number); + let parent_id = parent_number(&description).map(|number| issue_id(project, number)); + let status = status_from_labels(&provider_state, &labels); + let priority = priority_from_labels(&labels); + let labels = labels + .into_iter() + .filter(|label| { + !label.starts_with(PRIORITY_LABEL_PREFIX) && !label.starts_with(STATUS_LABEL_PREFIX) + }) + .collect::>(); + LoopItem { + id, + resource_type: "task".to_owned(), + project_space: project.project_space.clone(), + cloud_project_id: Some(project.id.clone()), + parent_id, + public_id: Some(web_url.clone()), + project_key: project.project_key.clone(), + name: None, + title: Some(title), + description, + sequence_number: Some(number), + next_item_number: None, + status: Some(status), + priority: Some(priority), + sort_order: number, + current_delivery_id: None, + metadata: json!({ + "task_provider": provider, + "issue_number": number, + "provider_state": provider_state, + "web_url": web_url, + "author": author, + "labels": labels, + "comments": comments, + }), + version: 1, + created_at, + updated_at, + completed_at: closed_at, + } +} + +fn issue_id(project: &LoopItem, number: i64) -> String { + format!( + "{}-{number}", + project.project_key.as_deref().unwrap_or("ISSUE") + ) +} + +fn issue_number(project: &LoopItem, task_id: &str) -> Result { + let prefix = format!("{}-", project.project_key.as_deref().unwrap_or("ISSUE")); + task_id + .strip_prefix(&prefix) + .unwrap_or(task_id) + .parse() + .map_err(|_| TaskRuntimeError::TaskNotFound) +} + +fn validate_external_priority(priority: &str) -> Result<(), TaskRuntimeError> { + if matches!(priority, "none" | "low" | "medium" | "high" | "urgent") { + return Ok(()); + } + Err(invalid(format!("unsupported task priority: {priority}"))) +} + +fn labels_for_write(mut tags: Vec, priority: &str, status: &str) -> Vec { + tags.retain(|label| { + !label.starts_with(PRIORITY_LABEL_PREFIX) && !label.starts_with(STATUS_LABEL_PREFIX) + }); + if priority != "none" { + tags.push(format!("{PRIORITY_LABEL_PREFIX}{priority}")); + } + tags.push(format!("{STATUS_LABEL_PREFIX}{status}")); + tags +} + +fn status_from_labels(provider_state: &str, labels: &[String]) -> String { + if provider_state == "closed" { + return "completed".to_owned(); + } + labels + .iter() + .find_map(|label| label.strip_prefix(STATUS_LABEL_PREFIX)) + .filter(|status| matches!(*status, "inbox" | "pending" | "in_progress" | "in_review")) + .unwrap_or("pending") + .to_owned() +} + +fn priority_from_labels(labels: &[String]) -> String { + labels + .iter() + .find_map(|label| label.strip_prefix(PRIORITY_LABEL_PREFIX)) + .filter(|priority| matches!(*priority, "low" | "medium" | "high" | "urgent")) + .unwrap_or("none") + .to_owned() +} + +fn validate_external_title(title: &str) -> Result<(), TaskRuntimeError> { + if title.trim().is_empty() { + return Err(invalid("task title is required")); + } + Ok(()) +} + +fn validate_external_status(status: &str) -> Result<(), TaskRuntimeError> { + if matches!( + status, + "inbox" | "pending" | "in_progress" | "in_review" | "completed" + ) { + return Ok(()); + } + Err(invalid(format!("unsupported task status: {status}"))) +} + +fn with_parent_marker(description: &str, parent: Option) -> String { + let content = description + .lines() + .filter(|line| !line.trim().starts_with(PARENT_MARKER)) + .collect::>() + .join("\n") + .trim_end() + .to_owned(); + match (content.is_empty(), parent) { + (_, None) => content, + (true, Some(number)) => format!("{PARENT_MARKER} #{number}"), + (false, Some(number)) => format!("{content}\n\n{PARENT_MARKER} #{number}"), + } +} + +fn insert_optional( + body: &mut serde_json::Map, + key: &str, + value: Option, +) { + if let Some(value) = value { + body.insert(key.to_owned(), json!(value)); + } +} + +fn parent_number(description: &str) -> Option { + description.lines().find_map(|line| { + let marker = line.trim().strip_prefix(PARENT_MARKER)?.trim(); + marker.trim_start_matches('#').parse().ok() + }) +} + +fn string_config(value: &serde_json::Map, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn encode_path_segment(value: &str) -> String { + byte_serialize(value.as_bytes()).collect() +} + +fn validate_provider_endpoint( + provider: TaskProviderKind, + domain: &str, + api_base: &str, +) -> Result<(), TaskRuntimeError> { + let url = Url::parse(api_base).map_err(|_| invalid("provider api_base is not a valid URL"))?; + let host = url + .host_str() + .ok_or_else(|| invalid("provider api_base must include a host"))?; + let official_github_api = provider == TaskProviderKind::Github + && domain.eq_ignore_ascii_case("github.com") + && host.eq_ignore_ascii_case("api.github.com"); + if !host.eq_ignore_ascii_case(domain) && !official_github_api { + return Err(invalid( + "provider api_base host must match provider_config.domain", + )); + } + let local_test_http = + cfg!(test) && url.scheme() == "http" && matches!(host, "127.0.0.1" | "localhost" | "::1"); + if url.scheme() != "https" && !local_test_http { + return Err(invalid("provider api_base must use HTTPS")); + } + if !url.username().is_empty() + || api_base.contains('@') + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid( + "provider api_base cannot contain credentials, query, or fragment", + )); + } + Ok(()) +} + +fn validate_github_repository(repository: &str) -> Result<(), TaskRuntimeError> { + let mut segments = repository.split('/'); + let owner = segments.next().unwrap_or_default(); + let name = segments.next().unwrap_or_default(); + let valid_segment = |value: &str| { + !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + }; + if !valid_segment(owner) || !valid_segment(name) || segments.next().is_some() { + return Err(invalid( + "GitHub repository must use the 'owner/repository' format", + )); + } + Ok(()) +} + +fn invalid(message: impl Into) -> TaskRuntimeError { + TaskRuntimeError::Invalid(message.into()) +} + +fn provider_error(error: impl std::fmt::Display) -> TaskRuntimeError { + TaskRuntimeError::ProviderRequest(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{ + labels_for_write, normalize_repository, parent_number, priority_from_labels, + status_from_labels, + }; + use crate::task_runtime::TaskProviderKind; + + #[test] + fn removes_gitlab_web_page_suffix_from_repository() { + assert_eq!( + normalize_repository("hongyu91/tab-prompt/-/issues", TaskProviderKind::Gitlab).unwrap(), + "hongyu91/tab-prompt" + ); + } + + #[test] + fn reads_controlled_parent_marker() { + assert_eq!(parent_number("Details\n\nWegent-Parent: #42\n"), Some(42)); + assert_eq!(parent_number("No hierarchy"), None); + } + + #[test] + fn maps_workflow_fields_to_controlled_issue_labels() { + let labels = labels_for_write( + vec![ + "bug".to_owned(), + "wegent:priority:low".to_owned(), + "wegent:status:pending".to_owned(), + ], + "high", + "in_review", + ); + assert_eq!( + labels, + vec!["bug", "wegent:priority:high", "wegent:status:in_review"] + ); + assert_eq!(priority_from_labels(&labels), "high"); + assert_eq!(status_from_labels("open", &labels), "in_review"); + assert_eq!(status_from_labels("opened", &labels), "in_review"); + } + + #[test] + fn closed_provider_state_wins_and_unlabeled_open_issues_default_to_pending() { + assert_eq!( + status_from_labels("closed", &["wegent:status:in_progress".to_owned()]), + "completed" + ); + assert_eq!(status_from_labels("open", &[]), "pending"); + assert_eq!( + status_from_labels("opened", &["wegent:status:completed".to_owned()]), + "pending" + ); + } + + #[test] + fn restores_every_open_workflow_status_from_labels() { + for status in ["inbox", "pending", "in_progress", "in_review"] { + let labels = labels_for_write(vec!["bug".to_owned()], "none", status); + assert_eq!(status_from_labels("opened", &labels), status); + } + } +} diff --git a/executor/src/task_runtime/mcp.rs b/executor/src/task_runtime/mcp.rs new file mode 100644 index 0000000000..4a7350eb12 --- /dev/null +++ b/executor/src/task_runtime/mcp.rs @@ -0,0 +1,400 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::env; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::protocol::ExecutionRequest; + +use super::{ProjectCreate, TaskRuntime}; + +const TASK_MCP_SERVER_NAME: &str = "wegent_tasks"; + +pub fn is_task_mcp_command() -> bool { + env::args().nth(1).as_deref() == Some("task-mcp-server") +} + +pub fn ensure_task_mcp_server(request: &mut ExecutionRequest) { + if request + .mcp_servers + .iter() + .any(|server| server.get("name").and_then(Value::as_str) == Some(TASK_MCP_SERVER_NAME)) + { + return; + } + let Ok(executable) = env::current_exe() else { + return; + }; + request.mcp_servers.push(json!({ + "name": TASK_MCP_SERVER_NAME, + "type": "stdio", + "command": executable, + "args": ["task-mcp-server"], + })); +} + +pub async fn run() -> Result<(), String> { + let runtime = TaskRuntime::from_env().map_err(|error| error.to_string())?; + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut stdout = tokio::io::stdout(); + while let Some(line) = lines.next_line().await.map_err(|error| error.to_string())? { + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => handle_request(&runtime, &request).await, + Err(error) => Some(error_response(Value::Null, -32700, &error.to_string())), + }; + if let Some(response) = response { + let mut encoded = serde_json::to_vec(&response).map_err(|error| error.to_string())?; + encoded.push(b'\n'); + stdout + .write_all(&encoded) + .await + .map_err(|error| error.to_string())?; + stdout.flush().await.map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +async fn handle_request(runtime: &TaskRuntime, request: &Value) -> Option { + let id = request.get("id").cloned(); + match request + .get("method") + .and_then(Value::as_str) + .unwrap_or_default() + { + "notifications/initialized" => None, + "initialize" => id.map(|id| { + result_response( + id, + json!({ + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": { + "name": TASK_MCP_SERVER_NAME, + "version": env!("CARGO_PKG_VERSION") + } + }), + ) + }), + "ping" => id.map(|id| result_response(id, json!({}))), + "tools/list" => id.map(|id| result_response(id, json!({"tools": tools()}))), + "tools/call" => { + let id = id?; + let name = request.pointer("/params/name").and_then(Value::as_str)?; + let arguments = request + .pointer("/params/arguments") + .cloned() + .unwrap_or_else(|| json!({})); + Some(result_response( + id, + call_tool(runtime, name, arguments).await, + )) + } + method => id.map(|id| error_response(id, -32601, &format!("Unknown method: {method}"))), + } +} + +async fn call_tool(runtime: &TaskRuntime, name: &str, arguments: Value) -> Value { + let result = match name { + "list_projects" => runtime + .list_projects() + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + "create_project" => parse(arguments) + .and_then(|input: ProjectCreate| runtime.create_project(input)) + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + "update_project" => { + let project_id = string_argument(&arguments, "project_id"); + let input = parse( + arguments + .get("project") + .cloned() + .unwrap_or_else(|| arguments.clone()), + ); + match (project_id, input) { + (Ok(project_id), Ok(input)) => runtime + .update_project(project_id, input) + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + "list_todos" => match string_argument(&arguments, "project_id") { + Ok(project_id) => runtime + .list_tasks(project_id) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + Err(error) => Err(error), + }, + "get_todo" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + match (project_id, task_id) { + (Ok(project_id), Ok(task_id)) => runtime + .get_task(project_id, task_id) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + "create_todo" => { + let project_id = string_argument(&arguments, "project_id"); + let input = parse( + arguments + .get("todo") + .cloned() + .unwrap_or_else(|| arguments.clone()), + ); + match (project_id, input) { + (Ok(project_id), Ok(input)) => runtime + .create_task(project_id, input) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + "update_todo" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + let input = parse( + arguments + .get("todo") + .cloned() + .unwrap_or_else(|| arguments.clone()), + ); + match (project_id, task_id, input) { + (Ok(project_id), Ok(task_id), Ok(input)) => runtime + .update_task(project_id, task_id, input) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } + "add_todo_comment" => { + let project_id = string_argument(&arguments, "project_id"); + let task_id = string_argument(&arguments, "task_id"); + let body = string_argument(&arguments, "body"); + match (project_id, task_id, body) { + (Ok(project_id), Ok(task_id), Ok(body)) => runtime + .add_comment(project_id, task_id, body) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => Err(error), + } + } + "reorder_todos" => { + let project_id = string_argument(&arguments, "project_id"); + let input = parse( + arguments + .get("reorder") + .cloned() + .unwrap_or_else(|| arguments.clone()), + ); + match (project_id, input) { + (Ok(project_id), Ok(input)) => runtime + .reorder_tasks(project_id, input) + .await + .and_then(|value| serde_json::to_value(value).map_err(invalid_json)), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + _ => return text_result(format!("Unknown task tool: {name}"), true), + }; + match result { + Ok(value) => text_result(value.to_string(), false), + Err(error) => text_result(error.to_string(), true), + } +} + +fn tools() -> Vec { + vec![ + tool( + "list_projects", + concat!( + "List project spaces available to this local Executor, including ", + "local spaces and configured cloud GitHub or GitLab spaces" + ), + json!({"type": "object", "properties": {}}), + ), + tool( + "create_project", + "Create a local project space; never use this to copy an existing cloud project", + json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "project_key": {"type": "string"}, + "description": {"type": "string"}, + "task_provider": {"enum": ["local", "github", "gitlab"]}, + "provider_config": {"type": "object"} + }, + "required": ["name", "task_provider"] + }), + ), + tool( + "update_project", + "Update a local project or rotate its encrypted provider credential", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "project": { + "type": "object", + "properties": { + "version": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "provider_config": { + "type": "object", + "description": "Provider settings. token is transient and is stored only as ciphertext." + } + }, + "required": ["version"] + } + }, + "required": ["project_id", "project"] + }), + ), + tool( + "list_todos", + "List tasks in a project", + json!({ + "type": "object", + "properties": {"project_id": {"type": "string"}}, + "required": ["project_id"] + }), + ), + tool( + "create_todo", + "Create a local task or external Issue", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "todo": {"type": "object"} + }, + "required": ["project_id", "todo"] + }), + ), + tool( + "get_todo", + "Get one task in a project", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"} + }, + "required": ["project_id", "task_id"] + }), + ), + tool( + "update_todo", + "Update a local task or external Issue", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"}, + "todo": {"type": "object"} + }, + "required": ["project_id", "task_id", "todo"] + }), + ), + tool( + "add_todo_comment", + "Add a comment to a GitHub or GitLab Issue", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "task_id": {"type": "string"}, + "body": {"type": "string"} + }, + "required": ["project_id", "task_id", "body"] + }), + ), + tool( + "reorder_todos", + "Persist the order of tasks in one board lane", + json!({ + "type": "object", + "properties": { + "project_id": {"type": "string"}, + "reorder": { + "type": "object", + "properties": { + "parent_id": {"type": ["string", "null"]}, + "status": {"type": "string"}, + "item_ids": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["status", "item_ids"] + } + }, + "required": ["project_id", "reorder"] + }), + ), + ] +} + +fn tool(name: &str, description: &str, input_schema: Value) -> Value { + json!({"name": name, "description": description, "inputSchema": input_schema}) +} + +fn parse(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| super::TaskRuntimeError::Invalid(error.to_string())) +} + +fn string_argument<'a>(value: &'a Value, key: &str) -> Result<&'a str, super::TaskRuntimeError> { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| super::TaskRuntimeError::Invalid(format!("{key} is required"))) +} + +fn invalid_json(error: serde_json::Error) -> super::TaskRuntimeError { + super::TaskRuntimeError::Invalid(error.to_string()) +} + +fn text_result(text: String, is_error: bool) -> Value { + json!({ + "content": [{"type": "text", "text": text}], + "isError": is_error + }) +} + +fn result_response(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn injects_the_local_task_mcp_once() { + let mut request = ExecutionRequest::default(); + + ensure_task_mcp_server(&mut request); + ensure_task_mcp_server(&mut request); + + assert_eq!(request.mcp_servers.len(), 1); + assert_eq!(request.mcp_servers[0]["name"], TASK_MCP_SERVER_NAME); + assert_eq!(request.mcp_servers[0]["type"], "stdio"); + assert_eq!(request.mcp_servers[0]["args"], json!(["task-mcp-server"])); + } +} diff --git a/executor/src/task_runtime/mod.rs b/executor/src/task_runtime/mod.rs new file mode 100644 index 0000000000..b37fa187de --- /dev/null +++ b/executor/src/task_runtime/mod.rs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +mod content; +mod credentials; +mod issue_provider; +pub mod mcp; +mod model; +mod router; +mod store; + +pub use model::{ + BinaryInput, Delivery, DeliveryAsset, DeliveryCreate, DeliveryDetail, IssueComment, LoopItem, + ProjectCreate, ProjectDescriptor, ProjectFile, ProjectStoreKind, ProjectUpdate, + RuntimeTaskAddress, TaskAttachment, TaskBinding, TaskCreate, TaskProviderKind, TaskReorder, + TaskUpdate, +}; +pub use router::TaskRuntime; +pub use store::{LocalTaskStore, TaskRuntimeError}; diff --git a/executor/src/task_runtime/model.rs b/executor/src/task_runtime/model.rs new file mode 100644 index 0000000000..538c092fd9 --- /dev/null +++ b/executor/src/task_runtime/model.rs @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProjectStoreKind { + Local, + Backend, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskProviderKind { + Local, + Backend, + Github, + Gitlab, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProjectCreate { + pub name: String, + pub project_key: Option, + #[serde(default)] + pub description: String, + pub task_provider: TaskProviderKind, + #[serde(default = "default_provider_config")] + pub provider_config: Value, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProjectDescriptor { + pub id: String, + pub public_id: Option, + pub project_key: String, + pub name: String, + #[serde(default)] + pub description: String, + pub project_store: ProjectStoreKind, + pub task_provider: TaskProviderKind, + #[serde(default = "default_provider_config")] + pub provider_config: Value, + #[serde(default = "default_version")] + pub version: i64, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ProjectUpdate { + pub version: i64, + pub name: Option, + pub description: Option, + pub tags: Option>, + pub provider_config: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TaskCreate { + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default = "default_status")] + pub status: String, + #[serde(default = "default_priority")] + pub priority: String, + pub parent_id: Option, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct TaskUpdate { + pub version: i64, + pub title: Option, + pub description: Option, + pub status: Option, + pub priority: Option, + pub parent_id: Option>, + pub tags: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TaskReorder { + pub parent_id: Option, + pub status: String, + pub item_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IssueComment { + pub id: String, + pub body: String, + pub author: String, + pub web_url: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuntimeTaskAddress { + #[serde(alias = "deviceId")] + pub device_id: String, + #[serde(alias = "taskId")] + pub task_id: String, + #[serde(default, alias = "taskTitle")] + pub task_title: Option, + #[serde(default, alias = "backendTaskId")] + pub backend_task_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TaskBinding { + pub id: String, + pub cloud_project_id: String, + pub loop_item_id: Option, + pub task_user_id: i64, + pub device_id: String, + pub task_id: String, + pub task_title: Option, + pub backend_task_id: Option, + pub linked_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BinaryInput { + pub display_name: String, + #[serde(default)] + pub content_type: Option, + pub base64: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProjectFile { + pub id: String, + pub cloud_project_id: String, + pub path: String, + pub name: String, + pub kind: String, + pub content_type: Option, + pub size_bytes: i64, + pub sha256: Option, + pub description: String, + pub created_by_user_id: i64, + pub updated_by_user_id: i64, + pub version: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TaskAttachment { + pub id: String, + pub loop_item_id: String, + pub display_name: String, + pub content_type: Option, + pub size_bytes: i64, + pub sha256: String, + pub created_by_user_id: i64, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeliveryAsset { + pub id: String, + pub kind: String, + pub display_name: String, + pub relative_path: String, + pub content_type: Option, + pub size_bytes: i64, + pub sha256: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Delivery { + pub id: String, + pub loop_item_id: String, + pub created_by_user_id: i64, + pub source_task_binding_id: Option, + pub source_task_snapshot: Option, + pub status: String, + pub created_at: String, + pub delivered_at: Option, + pub assets: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeliveryDetail { + #[serde(flatten)] + pub delivery: Delivery, + pub markdown: String, + pub chat: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeliveryCreate { + #[serde(default)] + pub markdown: String, + #[serde(default)] + pub chat: Option, + #[serde(default)] + pub source_task: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LoopItem { + pub id: String, + pub resource_type: String, + pub project_space: String, + pub cloud_project_id: Option, + pub parent_id: Option, + pub public_id: Option, + pub project_key: Option, + pub name: Option, + pub title: Option, + pub description: String, + pub sequence_number: Option, + pub next_item_number: Option, + pub status: Option, + pub priority: Option, + pub sort_order: i64, + pub current_delivery_id: Option, + pub metadata: Value, + pub version: i64, + pub created_at: String, + pub updated_at: String, + pub completed_at: Option, +} + +pub fn default_status() -> String { + "inbox".to_owned() +} + +pub fn default_priority() -> String { + "none".to_owned() +} + +fn default_version() -> i64 { + 1 +} + +fn default_provider_config() -> Value { + Value::Object(Default::default()) +} diff --git a/executor/src/task_runtime/router.rs b/executor/src/task_runtime/router.rs new file mode 100644 index 0000000000..2e00b5b6f4 --- /dev/null +++ b/executor/src/task_runtime/router.rs @@ -0,0 +1,928 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use super::{ + credentials::mask_provider_config, issue_provider::IssueProvider, store::task_provider, + BinaryInput, Delivery, DeliveryAsset, DeliveryCreate, DeliveryDetail, IssueComment, + LocalTaskStore, LoopItem, ProjectCreate, ProjectDescriptor, ProjectFile, ProjectUpdate, + RuntimeTaskAddress, TaskAttachment, TaskBinding, TaskCreate, TaskProviderKind, TaskReorder, + TaskRuntimeError, TaskUpdate, +}; + +/// Routes project and task operations to the provider configured on each project. +/// +/// The local store remains the project catalog for local projects. External issue +/// providers will plug in here and return runtime DTOs without persisting issues as +/// task rows. +#[derive(Clone)] +pub struct TaskRuntime { + local_store: LocalTaskStore, + issue_provider: IssueProvider, +} + +impl TaskRuntime { + pub fn from_env() -> Result { + Self::new(LocalTaskStore::from_env()?) + } + + pub fn new(local_store: LocalTaskStore) -> Result { + let issue_provider = IssueProvider::new(local_store.path().to_owned())?; + Ok(Self { + local_store, + issue_provider, + }) + } + + pub fn list_projects(&self) -> Result, TaskRuntimeError> { + self.local_store + .list_projects() + .map(|projects| projects.into_iter().map(mask_project).collect()) + } + + pub fn create_project(&self, input: ProjectCreate) -> Result { + self.local_store.create_project(input).map(mask_project) + } + + pub fn update_project( + &self, + project_id: &str, + input: ProjectUpdate, + ) -> Result { + self.local_store + .update_project(project_id, input) + .map(mask_project) + } + + pub fn configure_external_project( + &self, + project: ProjectDescriptor, + ) -> Result { + self.local_store + .configure_external_project(project) + .map(mask_project) + } + + pub async fn list_external_tasks( + &self, + project: ProjectDescriptor, + ) -> Result, TaskRuntimeError> { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider.list(&project, provider).await + } + + pub async fn get_external_task( + &self, + project: ProjectDescriptor, + task_id: &str, + ) -> Result { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider.get(&project, provider, task_id).await + } + + pub async fn create_external_task( + &self, + project: ProjectDescriptor, + input: TaskCreate, + ) -> Result { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider.create(&project, provider, input).await + } + + pub async fn update_external_task( + &self, + project: ProjectDescriptor, + task_id: &str, + input: TaskUpdate, + ) -> Result { + let provider = project.task_provider; + let project = self.local_store.external_project(project)?; + self.issue_provider + .update(&project, provider, task_id, input) + .await + } + + pub async fn list_tasks(&self, project_id: &str) -> Result, TaskRuntimeError> { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => self.local_store.list_tasks(project_id), + provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { + self.issue_provider.list(&project, provider).await + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub async fn get_task( + &self, + project_id: &str, + task_id: &str, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => self.local_store.get_task(project_id, task_id), + provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { + self.issue_provider.get(&project, provider, task_id).await + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub async fn create_task( + &self, + project_id: &str, + input: TaskCreate, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => self.local_store.create_task(project_id, input), + provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { + self.issue_provider.create(&project, provider, input).await + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub async fn update_task( + &self, + project_id: &str, + task_id: &str, + input: TaskUpdate, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => self.local_store.update_task(project_id, task_id, input), + provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { + self.issue_provider + .update(&project, provider, task_id, input) + .await + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub async fn add_comment( + &self, + project_id: &str, + task_id: &str, + body: &str, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { + self.issue_provider + .add_comment(&project, provider, task_id, body) + .await + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} comments" + ))), + } + } + + pub async fn reorder_tasks( + &self, + project_id: &str, + input: TaskReorder, + ) -> Result, TaskRuntimeError> { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => self.local_store.reorder_tasks(project_id, input), + TaskProviderKind::Github | TaskProviderKind::Gitlab => { + self.list_tasks(project_id).await + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))), + } + } + + pub async fn bind_task( + &self, + project_id: &str, + item_id: Option<&str>, + input: RuntimeTaskAddress, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match (task_provider(&project)?, item_id) { + (TaskProviderKind::Local, item_id) => { + self.local_store.bind_task(project_id, item_id, None, input) + } + (provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab), Some(item_id)) => { + self.issue_provider.get(&project, provider, item_id).await?; + self.local_store + .bind_task(project_id, None, Some(item_id), input) + } + (TaskProviderKind::Github | TaskProviderKind::Gitlab, None) => { + self.local_store.bind_task(project_id, None, None, input) + } + (provider, _) => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} bindings" + ))), + } + } + + pub fn list_task_bindings(&self, item_id: &str) -> Result, TaskRuntimeError> { + self.local_store.list_task_bindings(item_id) + } + + pub fn find_task_binding( + &self, + device_id: &str, + task_id: &str, + ) -> Result { + self.local_store.find_task_binding(device_id, task_id) + } + + pub fn unbind_task(&self, device_id: &str, task_id: &str) -> Result<(), TaskRuntimeError> { + self.local_store.unbind_task(device_id, task_id) + } + + pub fn list_project_files( + &self, + project_id: &str, + ) -> Result, TaskRuntimeError> { + self.local_store.list_project_files(project_id) + } + + pub fn create_project_folder( + &self, + project_id: &str, + path: &str, + ) -> Result { + self.local_store.create_project_folder(project_id, path) + } + + pub fn upload_project_file( + &self, + project_id: &str, + path: Option<&str>, + input: BinaryInput, + ) -> Result { + self.local_store + .upload_project_file(project_id, path, input) + } + + pub fn move_project_file( + &self, + file_id: &str, + path: &str, + version: i64, + ) -> Result { + self.local_store.move_project_file(file_id, path, version) + } + + pub fn delete_project_file( + &self, + file_id: &str, + recursive: bool, + ) -> Result<(), TaskRuntimeError> { + self.local_store.delete_project_file(file_id, recursive) + } + + pub fn project_file_path(&self, file_id: &str) -> Result { + Ok(self + .local_store + .project_file_path(file_id)? + .display() + .to_string()) + } + + pub async fn add_task_attachment( + &self, + project_id: &str, + item_id: &str, + input: BinaryInput, + ) -> Result { + let persisted = self.content_target(project_id, item_id).await?; + self.local_store + .add_task_attachment(project_id, item_id, persisted, input) + } + + pub fn list_task_attachments( + &self, + item_id: &str, + ) -> Result, TaskRuntimeError> { + self.local_store.list_task_attachments(item_id) + } + + pub fn task_attachment_path(&self, attachment_id: &str) -> Result { + Ok(self + .local_store + .task_attachment_path(attachment_id)? + .display() + .to_string()) + } + + pub fn delete_task_attachment(&self, attachment_id: &str) -> Result<(), TaskRuntimeError> { + self.local_store.delete_task_attachment(attachment_id) + } + + pub async fn create_delivery( + &self, + project_id: &str, + item_id: &str, + input: DeliveryCreate, + ) -> Result { + let persisted = self.content_target(project_id, item_id).await?; + self.local_store + .create_delivery(project_id, item_id, persisted, input) + } + + pub fn add_delivery_asset( + &self, + delivery_id: &str, + relative_path: &str, + input: BinaryInput, + ) -> Result { + self.local_store + .add_delivery_asset(delivery_id, relative_path, input) + } + + pub fn finalize_delivery( + &self, + item_id: &str, + delivery_id: &str, + ) -> Result { + self.local_store.finalize_delivery(item_id, delivery_id) + } + + pub fn discard_delivery(&self, delivery_id: &str) -> Result<(), TaskRuntimeError> { + self.local_store.discard_delivery(delivery_id) + } + + pub fn list_deliveries(&self, item_id: &str) -> Result, TaskRuntimeError> { + self.local_store.list_deliveries(item_id) + } + + pub fn delivery_detail(&self, delivery_id: &str) -> Result { + self.local_store.delivery_detail(delivery_id) + } + + pub fn delivery_asset_path(&self, asset_id: &str) -> Result { + Ok(self + .local_store + .delivery_asset_path(asset_id)? + .display() + .to_string()) + } + + async fn content_target( + &self, + project_id: &str, + item_id: &str, + ) -> Result { + let project = self.local_store.get_project(project_id)?; + match task_provider(&project)? { + TaskProviderKind::Local => { + self.local_store.get_task(project_id, item_id)?; + Ok(true) + } + provider @ (TaskProviderKind::Github | TaskProviderKind::Gitlab) => { + self.issue_provider.get(&project, provider, item_id).await?; + Ok(false) + } + provider => Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?} content" + ))), + } + } +} + +fn mask_project(mut project: LoopItem) -> LoopItem { + if let Some(provider_config) = project.metadata.get_mut("provider_config") { + mask_provider_config(provider_config); + } + project +} + +#[cfg(test)] +mod tests { + use axum::{http::HeaderMap, routing::get, Json, Router}; + use serde_json::json; + + use crate::task_runtime::ProjectStoreKind; + + use super::*; + + async fn github_issues() -> Json { + Json(json!([ + { + "number": 7, + "title": "External issue", + "body": "Details\n\nWegent-Parent: #3", + "state": "open", + "html_url": "https://github.test/acme/repo/issues/7", + "user": {"login": "octocat"}, + "labels": [ + {"name": "bug"}, + {"name": "wegent:status:in_review"} + ], + "comments": 2, + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-02T00:00:00Z", + "closed_at": null, + "pull_request": null + }, + { + "number": 8, + "title": "Pull request", + "body": null, + "state": "open", + "html_url": "https://github.test/acme/repo/pull/8", + "user": {"login": "octocat"}, + "labels": [], + "comments": 0, + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-02T00:00:00Z", + "closed_at": null, + "pull_request": {"url": "https://api.github.test/pulls/8"} + } + ])) + } + + async fn github_issue() -> Json { + Json(github_issues().await.0[0].clone()) + } + + fn github_issue_response( + number: i64, + title: &str, + body: &str, + state: &str, + labels: &[&str], + ) -> serde_json::Value { + json!({ + "number": number, + "title": title, + "body": body, + "state": state, + "html_url": format!("https://github.test/acme/repo/issues/{number}"), + "user": {"login": "octocat"}, + "labels": labels + .iter() + .map(|name| json!({"name": name})) + .collect::>(), + "comments": 0, + "created_at": "2026-07-05T00:00:00Z", + "updated_at": "2026-07-06T00:00:00Z", + "closed_at": (state == "closed").then_some("2026-07-06T00:00:00Z"), + "pull_request": null + }) + } + + async fn create_github_issue( + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("authorization").unwrap(), "Bearer test-token"); + assert_eq!(body["title"], "Created issue"); + assert_eq!(body["body"], "Description\n\nWegent-Parent: #7"); + assert_eq!(body["labels"], json!(["bug", "wegent:status:pending"])); + Json(github_issue_response( + 10, + "Created issue", + body["body"].as_str().unwrap(), + "open", + &["bug", "wegent:status:pending"], + )) + } + + async fn get_created_github_issue() -> Json { + Json(github_issue_response( + 10, + "Created issue", + "Description\n\nWegent-Parent: #7", + "open", + &["bug", "wegent:status:pending"], + )) + } + + async fn update_github_issue( + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("authorization").unwrap(), "Bearer test-token"); + let completed = body["state"] == "closed"; + let expected_status = if completed { + "wegent:status:completed" + } else { + "wegent:status:in_progress" + }; + assert_eq!(body["labels"], json!(["bug", expected_status])); + Json(github_issue_response( + 10, + body["title"].as_str().unwrap_or("Created issue"), + "Description\n\nWegent-Parent: #7", + if completed { "closed" } else { "open" }, + &["bug", expected_status], + )) + } + + async fn create_github_comment( + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("authorization").unwrap(), "Bearer test-token"); + assert_eq!(body["body"], "Shipped"); + Json(json!({ + "id": 99, + "body": "Shipped", + "html_url": "https://github.test/acme/repo/issues/10#issuecomment-99", + "user": {"login": "octocat"}, + "created_at": "2026-07-06T01:00:00Z", + "updated_at": "2026-07-06T01:00:00Z" + })) + } + + async fn gitlab_issues() -> Json { + Json(json!([{ + "iid": 9, + "title": "GitLab issue", + "description": "Done", + "state": "closed", + "web_url": "https://gitlab.test/acme/repo/-/issues/9", + "author": {"username": "fox"}, + "labels": ["delivery", "wegent:status:in_progress"], + "user_notes_count": 4, + "created_at": "2026-07-03T00:00:00Z", + "updated_at": "2026-07-04T00:00:00Z", + "closed_at": "2026-07-04T00:00:00Z" + }])) + } + + fn gitlab_issue_response(title: &str, state: &str, labels: &[&str]) -> serde_json::Value { + json!({ + "iid": 11, + "title": title, + "description": "GitLab body\n\nWegent-Parent: #9", + "state": state, + "web_url": "https://gitlab.test/acme/repo/-/issues/11", + "author": {"username": "fox"}, + "labels": labels, + "user_notes_count": 0, + "created_at": "2026-07-07T00:00:00Z", + "updated_at": "2026-07-08T00:00:00Z", + "closed_at": (state == "closed").then_some("2026-07-08T00:00:00Z") + }) + } + + async fn create_gitlab_issue( + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + assert_eq!(body["title"], "GitLab created"); + assert_eq!(body["description"], "GitLab body\n\nWegent-Parent: #9"); + assert_eq!(body["labels"], "delivery,wegent:status:pending"); + Json(gitlab_issue_response( + "GitLab created", + "opened", + &["delivery", "wegent:status:pending"], + )) + } + + async fn get_created_gitlab_issue() -> Json { + Json(gitlab_issue_response( + "GitLab created", + "opened", + &["delivery", "wegent:status:pending"], + )) + } + + async fn update_gitlab_issue( + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + let completed = body["state_event"] == "close"; + let expected_status = if completed { + "wegent:status:completed" + } else { + "wegent:status:in_progress" + }; + assert_eq!(body["labels"], format!("delivery,{expected_status}")); + Json(gitlab_issue_response( + "GitLab created", + if completed { "closed" } else { "opened" }, + &["delivery", expected_status], + )) + } + + async fn create_gitlab_comment( + headers: HeaderMap, + Json(body): Json, + ) -> Json { + assert_eq!(headers.get("private-token").unwrap(), "test-token"); + assert_eq!(body["body"], "GitLab shipped"); + Json(json!({ + "id": 101, + "body": "GitLab shipped", + "author": {"username": "fox"}, + "created_at": "2026-07-08T01:00:00Z", + "updated_at": "2026-07-08T01:00:00Z" + })) + } + + #[tokio::test] + async fn routes_github_issues_without_persisting_task_rows() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/repos/acme/repo/issues", get(github_issues)) + .route("/repos/acme/repo/issues/7", get(github_issue)), + ) + .await + .unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "GitHub".to_owned(), + project_key: Some("GH".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Github, + provider_config: json!({ + "repository": "acme/repo", + "domain": "127.0.0.1", + "api_base": format!("http://{address}") + }), + }) + .unwrap(); + let runtime = TaskRuntime::new(store.clone()).unwrap(); + + let issues = runtime.list_tasks(&project.id).await.unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].id, "GH-7"); + assert_eq!(issues[0].parent_id.as_deref(), Some("GH-3")); + assert_eq!(issues[0].status.as_deref(), Some("in_review")); + assert_eq!(issues[0].metadata["labels"], json!(["bug"])); + let issue = runtime.get_task(&project.id, "GH-7").await.unwrap(); + assert_eq!(issue.title.as_deref(), Some("External issue")); + assert!(matches!( + store.get_task(&project.id, "GH-7"), + Err(TaskRuntimeError::TaskNotFound) + )); + server.abort(); + } + + #[tokio::test] + async fn routes_gitlab_issues_and_maps_closed_state() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/projects/12/issues", get(gitlab_issues)), + ) + .await + .unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "GitLab".to_owned(), + project_key: Some("GL".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Gitlab, + provider_config: json!({ + "repository": "12", + "domain": "127.0.0.1", + "api_base": format!("http://{address}") + }), + }) + .unwrap(); + let runtime = TaskRuntime::new(store).unwrap(); + + let issues = runtime.list_tasks(&project.id).await.unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].id, "GL-9"); + assert_eq!(issues[0].status.as_deref(), Some("completed")); + assert_eq!( + issues[0].completed_at.as_deref(), + Some("2026-07-04T00:00:00Z") + ); + server.abort(); + } + + #[tokio::test] + async fn creates_updates_and_comments_on_github_without_task_rows() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route( + "/repos/acme/repo/issues", + get(github_issues).post(create_github_issue), + ) + .route( + "/repos/acme/repo/issues/10", + get(get_created_github_issue).patch(update_github_issue), + ) + .route( + "/repos/acme/repo/issues/10/comments", + axum::routing::post(create_github_comment), + ), + ) + .await + .unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let project = store + .create_project(ProjectCreate { + name: "GitHub writes".to_owned(), + project_key: Some("GH".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Github, + provider_config: json!({ + "repository": "acme/repo", + "domain": "127.0.0.1", + "api_base": format!("http://{address}"), + "token": "test-token" + }), + }) + .unwrap(); + let runtime = TaskRuntime::new(store.clone()).unwrap(); + + let created = runtime + .create_task( + &project.id, + TaskCreate { + title: "Created issue".to_owned(), + description: "Description".to_owned(), + status: "pending".to_owned(), + priority: "none".to_owned(), + parent_id: Some("GH-7".to_owned()), + tags: vec!["bug".to_owned()], + }, + ) + .await + .unwrap(); + let in_progress = runtime + .update_task( + &project.id, + &created.id, + TaskUpdate { + version: created.version, + status: Some("in_progress".to_owned()), + ..TaskUpdate::default() + }, + ) + .await + .unwrap(); + let completed = runtime + .update_task( + &project.id, + &created.id, + TaskUpdate { + version: created.version, + title: Some("Finished issue".to_owned()), + status: Some("completed".to_owned()), + ..TaskUpdate::default() + }, + ) + .await + .unwrap(); + let comment = runtime + .add_comment(&project.id, &created.id, "Shipped") + .await + .unwrap(); + + assert_eq!(created.id, "GH-10"); + assert_eq!(in_progress.status.as_deref(), Some("in_progress")); + assert_eq!(in_progress.metadata["labels"], json!(["bug"])); + assert_eq!(completed.status.as_deref(), Some("completed")); + assert_eq!(comment.id, "99"); + assert!(matches!( + store.get_task(&project.id, "GH-10"), + Err(TaskRuntimeError::TaskNotFound) + )); + server.abort(); + } + + #[tokio::test] + async fn creates_updates_and_comments_on_gitlab_without_task_rows() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route( + "/projects/12/issues", + get(gitlab_issues).post(create_gitlab_issue), + ) + .route( + "/projects/12/issues/11", + get(get_created_gitlab_issue).put(update_gitlab_issue), + ) + .route( + "/projects/12/issues/11/notes", + axum::routing::post(create_gitlab_comment), + ), + ) + .await + .unwrap(); + }); + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + let runtime = TaskRuntime::new(store.clone()).unwrap(); + let project = runtime + .configure_external_project(ProjectDescriptor { + id: "cloud-gitlab-12".to_owned(), + public_id: Some("cloud-gitlab-public".to_owned()), + name: "GitLab writes".to_owned(), + project_key: "GL".to_owned(), + description: String::new(), + project_store: ProjectStoreKind::Backend, + task_provider: TaskProviderKind::Gitlab, + provider_config: json!({ + "repository": "12", + "domain": "127.0.0.1", + "api_base": format!("http://{address}"), + "token": "test-token" + }), + version: 1, + }) + .unwrap(); + + let created = runtime + .create_task( + &project.id, + TaskCreate { + title: "GitLab created".to_owned(), + description: "GitLab body".to_owned(), + status: "pending".to_owned(), + priority: "none".to_owned(), + parent_id: Some("GL-9".to_owned()), + tags: vec!["delivery".to_owned()], + }, + ) + .await + .unwrap(); + let in_progress = runtime + .update_task( + &project.id, + &created.id, + TaskUpdate { + version: created.version, + status: Some("in_progress".to_owned()), + ..TaskUpdate::default() + }, + ) + .await + .unwrap(); + let completed = runtime + .update_task( + &project.id, + &created.id, + TaskUpdate { + version: created.version, + status: Some("completed".to_owned()), + ..TaskUpdate::default() + }, + ) + .await + .unwrap(); + let comment = runtime + .add_comment(&project.id, &created.id, "GitLab shipped") + .await + .unwrap(); + + assert_eq!(created.id, "GL-11"); + assert_eq!( + project.metadata["project_store"], + json!(ProjectStoreKind::Backend) + ); + assert_eq!(in_progress.status.as_deref(), Some("in_progress")); + assert_eq!(in_progress.metadata["labels"], json!(["delivery"])); + assert_eq!(completed.status.as_deref(), Some("completed")); + assert_eq!(comment.id, "101"); + assert!(matches!( + store.get_task(&project.id, "GL-11"), + Err(TaskRuntimeError::TaskNotFound) + )); + server.abort(); + } +} diff --git a/executor/src/task_runtime/store.rs b/executor/src/task_runtime/store.rs new file mode 100644 index 0000000000..14549cd239 --- /dev/null +++ b/executor/src/task_runtime/store.rs @@ -0,0 +1,1535 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + env, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use chrono::Utc; +use rusqlite::{params, Connection, OptionalExtension, Row, TransactionBehavior}; +use serde_json::json; +use thiserror::Error; +use uuid::Uuid; + +use super::credentials::{encrypt_provider_config, update_provider_config}; +use super::model::{ + LoopItem, ProjectCreate, ProjectDescriptor, ProjectStoreKind, ProjectUpdate, + RuntimeTaskAddress, TaskBinding, TaskCreate, TaskProviderKind, TaskReorder, TaskUpdate, +}; + +const LOCAL_SCHEMA_VERSION: i64 = 3; + +#[derive(Debug, Error)] +pub enum TaskRuntimeError { + #[error("local task database error: {0}")] + Database(#[from] rusqlite::Error), + #[error("invalid task data: {0}")] + Invalid(String), + #[error("project not found")] + ProjectNotFound, + #[error("task not found")] + TaskNotFound, + #[error("task changed")] + VersionConflict, + #[error("task provider '{0}' is not implemented yet")] + UnsupportedProvider(String), + #[error("task provider request failed: {0}")] + ProviderRequest(String), + #[error("local task database is unavailable")] + LockPoisoned, +} + +#[derive(Clone)] +pub struct LocalTaskStore { + connection: Arc>, + path: PathBuf, +} + +impl LocalTaskStore { + pub fn from_env() -> Result { + Self::open(local_database_path()) + } + + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + } + let connection = Connection::open(&path)?; + connection.pragma_update(None, "foreign_keys", "ON")?; + connection.pragma_update(None, "journal_mode", "WAL")?; + migrate(&connection)?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + path, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn list_projects(&self) -> Result, TaskRuntimeError> { + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT id, resource_type, project_space, cloud_project_id, parent_id, + public_id, project_key, name, title, description, sequence_number, + next_item_number, status, priority, sort_order, current_delivery_id, + metadata, version, created_at, updated_at, completed_at + FROM loop_items + WHERE resource_type = 'project' AND deleted_at IS NULL + ORDER BY updated_at DESC", + )?; + let rows = statement.query_map([], map_loop_item)?; + let mut projects = collect_items(rows)?; + drop(statement); + projects.extend(list_external_projects(&connection)?); + projects.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + Ok(projects) + } + + pub fn create_project(&self, input: ProjectCreate) -> Result { + validate_name(&input.name, "project name")?; + validate_provider(ProjectStoreKind::Local, input.task_provider)?; + let provider_config = + encrypt_provider_config(self.path(), input.task_provider, input.provider_config)?; + let public_id = Uuid::new_v4().to_string(); + let id = public_id.clone(); + let project_key = normalize_project_key(input.project_key, &input.name); + let now = now(); + let metadata = json!({ + "project_store": ProjectStoreKind::Local, + "task_provider": input.task_provider, + "provider_config": provider_config, + "tags": [], + }); + let connection = self.connection()?; + connection.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, public_id, project_key, name, + description, storage_prefix, next_item_number, status, sort_order, + metadata, version, created_at, updated_at + ) VALUES (?1, 'project', 'default', ?2, ?3, ?4, ?5, ?6, 1, 'active', + 0, ?7, 1, ?8, ?8)", + params![ + id, + public_id, + project_key, + input.name, + input.description, + format!("projects/{public_id}"), + metadata.to_string(), + now, + ], + )?; + drop(connection); + self.get_item(&id, "project") + } + + pub fn configure_external_project( + &self, + project: ProjectDescriptor, + ) -> Result { + validate_provider(project.project_store, project.task_provider)?; + if !matches!( + project.task_provider, + TaskProviderKind::Github | TaskProviderKind::Gitlab + ) { + return Err(TaskRuntimeError::Invalid( + "external project requires github or gitlab".to_owned(), + )); + } + let connection = self.connection()?; + let current = provider_credential_config( + &connection, + project_store_key(project.project_store), + &project.id, + )? + .unwrap_or_else(|| json!({})); + let provider_config = update_provider_config( + self.path(), + project.task_provider, + ¤t, + project.provider_config.clone(), + )?; + connection.execute( + "INSERT INTO project_provider_credentials ( + project_store, project_id, task_provider, provider_config, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(project_store, project_id) DO UPDATE SET + task_provider = excluded.task_provider, + provider_config = excluded.provider_config, + updated_at = excluded.updated_at", + params![ + project_store_key(project.project_store), + project.id, + task_provider_key(project.task_provider), + provider_config.to_string(), + now(), + ], + )?; + if project.project_store == ProjectStoreKind::Backend { + let mut descriptor = project.clone(); + descriptor.provider_config = json!({}); + connection.execute( + "INSERT INTO external_project_catalog ( + project_store, project_id, descriptor, updated_at + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(project_store, project_id) DO UPDATE SET + descriptor = excluded.descriptor, + updated_at = excluded.updated_at", + params![ + project_store_key(project.project_store), + project.id, + serde_json::to_string(&descriptor) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?, + now(), + ], + )?; + } + Ok(descriptor_loop_item(project, provider_config)) + } + + pub fn external_project( + &self, + project: ProjectDescriptor, + ) -> Result { + validate_provider(project.project_store, project.task_provider)?; + let connection = self.connection()?; + let provider_config = provider_credential_config( + &connection, + project_store_key(project.project_store), + &project.id, + )? + .unwrap_or_else(|| project.provider_config.clone()); + Ok(descriptor_loop_item(project, provider_config)) + } + + pub fn update_project( + &self, + project_id: &str, + input: ProjectUpdate, + ) -> Result { + let project = self.get_project(project_id)?; + if project.version != input.version { + return Err(TaskRuntimeError::VersionConflict); + } + if let Some(name) = input.name.as_deref() { + validate_name(name, "project name")?; + } + let provider = task_provider(&project)?; + let mut metadata = project.metadata; + if let Some(tags) = input.tags { + metadata["tags"] = json!(tags); + } + if let Some(provider_config) = input.provider_config { + metadata["provider_config"] = update_provider_config( + self.path(), + provider, + &metadata["provider_config"], + provider_config, + )?; + } + let connection = self.connection()?; + let updated = connection.execute( + "UPDATE loop_items + SET name = COALESCE(?1, name), + description = COALESCE(?2, description), + metadata = ?3, + version = version + 1, + updated_at = ?4 + WHERE id = ?5 AND resource_type = 'project' AND version = ?6 + AND deleted_at IS NULL", + params![ + input.name.map(|value| value.trim().to_owned()), + input.description, + metadata.to_string(), + now(), + project_id, + input.version, + ], + )?; + if updated == 0 { + return Err(TaskRuntimeError::VersionConflict); + } + drop(connection); + self.get_project(project_id) + } + + pub fn list_tasks(&self, project_id: &str) -> Result, TaskRuntimeError> { + let project = self.get_project(project_id)?; + let provider = task_provider(&project)?; + if provider != TaskProviderKind::Local { + return Err(TaskRuntimeError::UnsupportedProvider(format!( + "{provider:?}" + ))); + } + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT id, resource_type, project_space, cloud_project_id, parent_id, + public_id, project_key, name, title, description, sequence_number, + next_item_number, status, priority, sort_order, current_delivery_id, + metadata, version, created_at, updated_at, completed_at + FROM loop_items + WHERE resource_type = 'task' AND cloud_project_id = ?1 + AND deleted_at IS NULL + ORDER BY sort_order, updated_at DESC", + )?; + let rows = statement.query_map([project_id], map_loop_item)?; + collect_items(rows) + } + + pub fn get_task(&self, project_id: &str, task_id: &str) -> Result { + let item = self.get_item(task_id, "task")?; + if item.cloud_project_id.as_deref() != Some(project_id) { + return Err(TaskRuntimeError::TaskNotFound); + } + Ok(item) + } + + pub fn create_task( + &self, + project_id: &str, + input: TaskCreate, + ) -> Result { + validate_name(&input.title, "task title")?; + validate_status(&input.status)?; + validate_priority(&input.priority)?; + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let project = get_item_from(&transaction, project_id, "project")? + .ok_or(TaskRuntimeError::ProjectNotFound)?; + if task_provider(&project)? != TaskProviderKind::Local { + return Err(TaskRuntimeError::UnsupportedProvider("external".to_owned())); + } + if let Some(parent_id) = input.parent_id.as_deref() { + require_parent(&transaction, project_id, parent_id, None)?; + } + let sequence = project.next_item_number.unwrap_or(1); + let project_key = project + .project_key + .as_deref() + .ok_or_else(|| TaskRuntimeError::Invalid("project key is missing".to_owned()))?; + let id = format!("{project_key}-{sequence}"); + let now = now(); + let completed_at = (input.status == "completed").then(|| now.clone()); + let metadata = json!({"tags": input.tags}); + transaction.execute( + "UPDATE loop_items SET next_item_number = ?1, version = version + 1, + updated_at = ?2 WHERE id = ?3", + params![sequence + 1, now, project_id], + )?; + transaction.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, parent_id, + title, description, sequence_number, status, priority, sort_order, + metadata, version, created_at, updated_at, completed_at + ) VALUES (?1, 'task', 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, + 0, ?9, 1, ?10, ?10, ?11)", + params![ + id, + project_id, + input.parent_id, + input.title, + input.description, + sequence, + input.status, + input.priority, + metadata.to_string(), + now, + completed_at, + ], + )?; + transaction.commit()?; + drop(connection); + self.get_item(&id, "task") + } + + pub fn update_task( + &self, + project_id: &str, + task_id: &str, + input: TaskUpdate, + ) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = + get_item_from(&transaction, task_id, "task")?.ok_or(TaskRuntimeError::TaskNotFound)?; + if current.cloud_project_id.as_deref() != Some(project_id) { + return Err(TaskRuntimeError::TaskNotFound); + } + if current.version != input.version { + return Err(TaskRuntimeError::VersionConflict); + } + if let Some(title) = input.title.as_deref() { + validate_name(title, "task title")?; + } + if let Some(status) = input.status.as_deref() { + validate_status(status)?; + } + if let Some(priority) = input.priority.as_deref() { + validate_priority(priority)?; + } + if let Some(Some(parent_id)) = input.parent_id.as_ref() { + require_parent(&transaction, project_id, parent_id, Some(task_id))?; + } + let title = input.title.or(current.title); + let description = input.description.unwrap_or(current.description); + let status = input.status.or(current.status); + let priority = input.priority.or(current.priority); + let parent_id = input.parent_id.unwrap_or(current.parent_id); + let mut metadata = current.metadata; + if let Some(tags) = input.tags { + metadata["tags"] = json!(tags); + } + let now = now(); + let completed_at = if status.as_deref() == Some("completed") { + current.completed_at.or_else(|| Some(now.clone())) + } else { + None + }; + let changed = transaction.execute( + "UPDATE loop_items + SET title = ?1, description = ?2, status = ?3, priority = ?4, + parent_id = ?5, metadata = ?6, completed_at = ?7, + version = version + 1, updated_at = ?8 + WHERE id = ?9 AND version = ?10", + params![ + title, + description, + status, + priority, + parent_id, + metadata.to_string(), + completed_at, + now, + task_id, + input.version, + ], + )?; + if changed != 1 { + return Err(TaskRuntimeError::VersionConflict); + } + transaction.commit()?; + drop(connection); + self.get_item(task_id, "task") + } + + pub fn reorder_tasks( + &self, + project_id: &str, + input: TaskReorder, + ) -> Result, TaskRuntimeError> { + validate_status(&input.status)?; + if input.item_ids.is_empty() { + return Err(TaskRuntimeError::Invalid( + "item_ids must contain at least one task".to_owned(), + )); + } + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let project = get_item_from(&transaction, project_id, "project")? + .ok_or(TaskRuntimeError::ProjectNotFound)?; + if task_provider(&project)? != TaskProviderKind::Local { + return Err(TaskRuntimeError::UnsupportedProvider("external".to_owned())); + } + let mut statement = transaction.prepare( + "SELECT id, resource_type, project_space, cloud_project_id, parent_id, + public_id, project_key, name, title, description, sequence_number, + next_item_number, status, priority, sort_order, current_delivery_id, + metadata, version, created_at, updated_at, completed_at + FROM loop_items + WHERE resource_type = 'task' AND cloud_project_id = ?1 AND status = ?2 + AND ((?3 IS NULL AND (parent_id IS NULL OR parent_id = '')) OR parent_id = ?3) + AND deleted_at IS NULL + ORDER BY sort_order, updated_at DESC", + )?; + let lane = collect_items(statement.query_map( + params![project_id, input.status, input.parent_id], + map_loop_item, + )?)?; + drop(statement); + let mut ordered_ids = input + .item_ids + .into_iter() + .filter(|id| lane.iter().any(|item| item.id == *id)) + .collect::>(); + if ordered_ids.is_empty() { + return Err(TaskRuntimeError::Invalid( + "task not found in lane".to_owned(), + )); + } + let remaining_ids = lane + .iter() + .filter(|item| !ordered_ids.contains(&item.id)) + .map(|item| item.id.clone()) + .collect::>(); + ordered_ids.extend(remaining_ids); + let updated_at = now(); + for (position, item_id) in ordered_ids.iter().enumerate() { + transaction.execute( + "UPDATE loop_items + SET sort_order = ?1, version = version + 1, updated_at = ?2 + WHERE id = ?3 AND sort_order != ?1", + params![position as i64, updated_at, item_id], + )?; + } + transaction.commit()?; + drop(connection); + let by_id = self + .list_tasks(project_id)? + .into_iter() + .map(|item| (item.id.clone(), item)) + .collect::>(); + Ok(ordered_ids + .into_iter() + .filter_map(|id| by_id.get(&id).cloned()) + .collect()) + } + + pub fn bind_task( + &self, + project_id: &str, + item_id: Option<&str>, + external_item_id: Option<&str>, + input: RuntimeTaskAddress, + ) -> Result { + validate_name(&input.device_id, "device id")?; + validate_name(&input.task_id, "task id")?; + self.get_project(project_id)?; + if let Some(item_id) = item_id { + self.get_task(project_id, item_id)?; + } + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let active = get_active_binding(&transaction, &input.device_id, &input.task_id)?; + let metadata = json!({"external_item_id": external_item_id}); + if let Some(active) = active { + let target_item_id = item_id.or(external_item_id); + let same_target = active.cloud_project_id == project_id + && active.loop_item_id.as_deref() == target_item_id; + if same_target { + transaction.execute( + "UPDATE loop_items SET task_title = ?1, backend_task_id = ?2, + updated_at = ?3 + WHERE id = ?4", + params![input.task_title, input.backend_task_id, now(), active.id], + )?; + transaction.commit()?; + drop(connection); + return self.get_binding(&active.id); + } + transaction.execute( + "UPDATE loop_items SET unlinked_at = ?1, updated_at = ?1 WHERE id = ?2", + params![now(), active.id], + )?; + } + let id = numeric_id(); + let linked_at = now(); + transaction.execute( + "INSERT INTO loop_items ( + id, resource_type, project_space, cloud_project_id, loop_item_id, + task_user_id, device_id, task_id, task_title, backend_task_id, + linked_by_user_id, linked_at, metadata, version, created_at, updated_at + ) VALUES (?1, 'execution', 'default', ?2, ?3, 0, ?4, ?5, ?6, ?7, + 0, ?8, ?9, 1, ?8, ?8)", + params![ + id, + project_id, + item_id, + input.device_id, + input.task_id, + input.task_title, + input.backend_task_id, + linked_at, + metadata.to_string(), + ], + )?; + transaction.commit()?; + drop(connection); + if let Some(item_id) = item_id { + self.advance_started_task(project_id, item_id)?; + } + self.get_binding(&id) + } + + pub fn list_task_bindings(&self, item_id: &str) -> Result, TaskRuntimeError> { + let connection = self.connection()?; + let mut statement = connection.prepare( + "SELECT id, cloud_project_id, + COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + task_user_id, device_id, + task_id, task_title, backend_task_id, linked_at + FROM loop_items + WHERE resource_type = 'execution' AND unlinked_at IS NULL + AND (loop_item_id = ?1 OR json_extract(metadata, '$.external_item_id') = ?1) + ORDER BY linked_at DESC", + )?; + let rows = statement.query_map([item_id], map_task_binding)?; + rows.collect::, _>>() + .map_err(TaskRuntimeError::from) + } + + pub fn find_task_binding( + &self, + device_id: &str, + task_id: &str, + ) -> Result { + let connection = self.connection()?; + get_active_binding(&connection, device_id, task_id)?.ok_or(TaskRuntimeError::TaskNotFound) + } + + pub fn unbind_task(&self, device_id: &str, task_id: &str) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + let timestamp = now(); + connection.execute( + "UPDATE loop_items SET unlinked_at = ?1, updated_at = ?1 + WHERE resource_type = 'execution' AND device_id = ?2 AND task_id = ?3 + AND unlinked_at IS NULL", + params![timestamp, device_id, task_id], + )?; + Ok(()) + } + + fn get_binding(&self, id: &str) -> Result { + let connection = self.connection()?; + connection + .query_row( + "SELECT id, cloud_project_id, + COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + task_user_id, device_id, + task_id, task_title, backend_task_id, linked_at + FROM loop_items WHERE id = ?1 AND resource_type = 'execution'", + [id], + map_task_binding, + ) + .optional()? + .ok_or(TaskRuntimeError::TaskNotFound) + } + + fn advance_started_task( + &self, + project_id: &str, + item_id: &str, + ) -> Result<(), TaskRuntimeError> { + let connection = self.connection()?; + connection.execute( + "UPDATE loop_items + SET status = 'in_progress', completed_at = NULL, version = version + 1, + updated_at = ?1 + WHERE id = ?2 AND cloud_project_id = ?3 AND status IN ('inbox', 'pending')", + params![now(), item_id, project_id], + )?; + Ok(()) + } + + pub(crate) fn get_project(&self, project_id: &str) -> Result { + let connection = self.connection()?; + if let Some(project) = get_item_from(&connection, project_id, "project")? { + return Ok(project); + } + external_project_from_catalog(&connection, project_id)? + .ok_or(TaskRuntimeError::ProjectNotFound) + } + + pub(crate) fn get_item( + &self, + id: &str, + resource_type: &str, + ) -> Result { + let connection = self.connection()?; + get_item_from(&connection, id, resource_type)?.ok_or(TaskRuntimeError::TaskNotFound) + } + + pub(crate) fn connection( + &self, + ) -> Result, TaskRuntimeError> { + self.connection + .lock() + .map_err(|_| TaskRuntimeError::LockPoisoned) + } +} + +fn get_active_binding( + connection: &Connection, + device_id: &str, + task_id: &str, +) -> Result, TaskRuntimeError> { + connection + .query_row( + "SELECT id, cloud_project_id, + COALESCE(loop_item_id, json_extract(metadata, '$.external_item_id')), + task_user_id, device_id, + task_id, task_title, backend_task_id, linked_at + FROM loop_items + WHERE resource_type = 'execution' AND device_id = ?1 AND task_id = ?2 + AND unlinked_at IS NULL + LIMIT 1", + params![device_id, task_id], + map_task_binding, + ) + .optional() + .map_err(TaskRuntimeError::from) +} + +fn map_task_binding(row: &Row<'_>) -> rusqlite::Result { + Ok(TaskBinding { + id: row.get(0)?, + cloud_project_id: row.get(1)?, + loop_item_id: row.get(2)?, + task_user_id: row.get::<_, Option>(3)?.unwrap_or(0), + device_id: row.get(4)?, + task_id: row.get(5)?, + task_title: row.get(6)?, + backend_task_id: row.get(7)?, + linked_at: row.get(8)?, + }) +} + +fn migrate(connection: &Connection) -> Result<(), TaskRuntimeError> { + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS loop_items ( + id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL, + project_space TEXT NOT NULL DEFAULT 'default', + cloud_project_id TEXT REFERENCES loop_items(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES loop_items(id) ON DELETE CASCADE, + loop_item_id TEXT REFERENCES loop_items(id) ON DELETE CASCADE, + delivery_id TEXT REFERENCES loop_items(id) ON DELETE CASCADE, + public_id TEXT UNIQUE, + project_key TEXT UNIQUE, + name TEXT, + title TEXT, + description TEXT NOT NULL DEFAULT '', + storage_prefix TEXT UNIQUE, + sequence_number INTEGER, + next_item_number INTEGER, + created_by_user_id INTEGER, + updated_by_user_id INTEGER, + assignee_user_id INTEGER, + user_id INTEGER, + added_by_user_id INTEGER, + source TEXT, + status TEXT, + priority TEXT, + due_at TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + current_delivery_id TEXT, + local_project_id INTEGER, + device_id TEXT, + is_default INTEGER, + task_user_id INTEGER, + task_id TEXT, + task_title TEXT, + backend_task_id INTEGER, + linked_by_user_id INTEGER, + linked_at TEXT, + unlinked_at TEXT, + path TEXT, + kind TEXT, + display_name TEXT, + relative_path TEXT, + object_key TEXT, + content_type TEXT, + size_bytes INTEGER, + sha256 TEXT, + source_task_binding_id TEXT, + source_task_snapshot TEXT, + markdown_object_key TEXT, + chat_object_key TEXT, + manifest_object_key TEXT, + metadata TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + delivered_at TEXT, + deleted_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_loop_items_project_type + ON loop_items(cloud_project_id, resource_type); + CREATE INDEX IF NOT EXISTS idx_loop_items_parent_type + ON loop_items(parent_id, resource_type, sort_order); + CREATE INDEX IF NOT EXISTS idx_loop_items_project_path + ON loop_items(cloud_project_id, path); + CREATE INDEX IF NOT EXISTS ix_loop_items_resource_type + ON loop_items(resource_type); + CREATE INDEX IF NOT EXISTS ix_loop_items_project_space + ON loop_items(project_space); + CREATE INDEX IF NOT EXISTS ix_loop_items_deleted_at + ON loop_items(deleted_at); + CREATE TABLE IF NOT EXISTS project_provider_credentials ( + project_store TEXT NOT NULL, + project_id TEXT NOT NULL, + task_provider TEXT NOT NULL, + provider_config TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(project_store, project_id) + ); + CREATE TABLE IF NOT EXISTS external_project_catalog ( + project_store TEXT NOT NULL, + project_id TEXT NOT NULL, + descriptor TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(project_store, project_id) + );", + )?; + connection.execute( + "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?1, ?2)", + params![LOCAL_SCHEMA_VERSION, now()], + )?; + Ok(()) +} + +fn get_item_from( + connection: &Connection, + id: &str, + resource_type: &str, +) -> Result, rusqlite::Error> { + connection + .query_row( + "SELECT id, resource_type, project_space, cloud_project_id, parent_id, + public_id, project_key, name, title, description, sequence_number, + next_item_number, status, priority, sort_order, current_delivery_id, + metadata, version, created_at, updated_at, completed_at + FROM loop_items + WHERE id = ?1 AND resource_type = ?2 AND deleted_at IS NULL", + params![id, resource_type], + map_loop_item, + ) + .optional() +} + +fn map_loop_item(row: &Row<'_>) -> rusqlite::Result { + Ok(LoopItem { + id: row.get(0)?, + resource_type: row.get(1)?, + project_space: row.get(2)?, + cloud_project_id: row.get(3)?, + parent_id: row.get(4)?, + public_id: row.get(5)?, + project_key: row.get(6)?, + name: row.get(7)?, + title: row.get(8)?, + description: row.get(9)?, + sequence_number: row.get(10)?, + next_item_number: row.get(11)?, + status: row.get(12)?, + priority: row.get(13)?, + sort_order: row.get(14)?, + current_delivery_id: row.get(15)?, + metadata: row + .get::<_, Option>(16)? + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_else(|| json!({})), + version: row.get(17)?, + created_at: row.get(18)?, + updated_at: row.get(19)?, + completed_at: row.get(20)?, + }) +} + +fn collect_items( + rows: rusqlite::MappedRows<'_, impl FnMut(&Row<'_>) -> rusqlite::Result>, +) -> Result, TaskRuntimeError> { + rows.collect::, _>>() + .map_err(TaskRuntimeError::from) +} + +pub(crate) fn task_provider(project: &LoopItem) -> Result { + serde_json::from_value( + project + .metadata + .get("task_provider") + .cloned() + .unwrap_or_else(|| json!("local")), + ) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string())) +} + +fn validate_provider( + store: ProjectStoreKind, + provider: TaskProviderKind, +) -> Result<(), TaskRuntimeError> { + let valid = matches!( + (store, provider), + (ProjectStoreKind::Local, TaskProviderKind::Local) + | (ProjectStoreKind::Local, TaskProviderKind::Github) + | (ProjectStoreKind::Local, TaskProviderKind::Gitlab) + | (ProjectStoreKind::Backend, TaskProviderKind::Backend) + | (ProjectStoreKind::Backend, TaskProviderKind::Github) + | (ProjectStoreKind::Backend, TaskProviderKind::Gitlab) + ); + valid + .then_some(()) + .ok_or_else(|| TaskRuntimeError::Invalid("invalid project/provider combination".to_owned())) +} + +fn project_store_key(store: ProjectStoreKind) -> &'static str { + match store { + ProjectStoreKind::Local => "local", + ProjectStoreKind::Backend => "backend", + } +} + +fn task_provider_key(provider: TaskProviderKind) -> &'static str { + match provider { + TaskProviderKind::Local => "local", + TaskProviderKind::Backend => "backend", + TaskProviderKind::Github => "github", + TaskProviderKind::Gitlab => "gitlab", + } +} + +fn provider_credential_config( + connection: &Connection, + project_store: &str, + project_id: &str, +) -> Result, TaskRuntimeError> { + let serialized = connection + .query_row( + "SELECT provider_config FROM project_provider_credentials + WHERE project_store = ?1 AND project_id = ?2", + params![project_store, project_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + serialized + .map(|value| { + serde_json::from_str(&value) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string())) + }) + .transpose() +} + +fn list_external_projects(connection: &Connection) -> Result, TaskRuntimeError> { + let mut statement = connection.prepare( + "SELECT descriptor + FROM external_project_catalog + WHERE project_store = 'backend' + ORDER BY updated_at DESC", + )?; + let descriptors = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + drop(statement); + descriptors + .into_iter() + .map(|serialized| { + let descriptor = serde_json::from_str::(&serialized) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + let provider_config = provider_credential_config( + connection, + project_store_key(descriptor.project_store), + &descriptor.id, + )? + .unwrap_or_else(|| json!({})); + Ok(descriptor_loop_item(descriptor, provider_config)) + }) + .collect() +} + +fn external_project_from_catalog( + connection: &Connection, + project_id: &str, +) -> Result, TaskRuntimeError> { + let serialized = connection + .query_row( + "SELECT descriptor + FROM external_project_catalog + WHERE project_store = 'backend' AND project_id = ?1", + [project_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + serialized + .map(|serialized| { + let descriptor = serde_json::from_str::(&serialized) + .map_err(|error| TaskRuntimeError::Invalid(error.to_string()))?; + let provider_config = provider_credential_config( + connection, + project_store_key(descriptor.project_store), + &descriptor.id, + )? + .unwrap_or_else(|| json!({})); + Ok(descriptor_loop_item(descriptor, provider_config)) + }) + .transpose() +} + +fn descriptor_loop_item( + project: ProjectDescriptor, + provider_config: serde_json::Value, +) -> LoopItem { + LoopItem { + id: project.id, + resource_type: "project".to_owned(), + project_space: "default".to_owned(), + cloud_project_id: None, + parent_id: None, + public_id: project.public_id, + project_key: Some(project.project_key), + name: Some(project.name), + title: None, + description: project.description, + sequence_number: None, + next_item_number: Some(1), + status: Some("active".to_owned()), + priority: None, + sort_order: 0, + current_delivery_id: None, + metadata: json!({ + "project_store": project.project_store, + "task_provider": project.task_provider, + "provider_config": provider_config, + "tags": [], + }), + version: project.version, + created_at: String::new(), + updated_at: String::new(), + completed_at: None, + } +} + +fn require_parent( + connection: &Connection, + project_id: &str, + parent_id: &str, + moving_task_id: Option<&str>, +) -> Result<(), TaskRuntimeError> { + let parent = get_item_from(connection, parent_id, "task")? + .filter(|item| item.cloud_project_id.as_deref() == Some(project_id)) + .ok_or_else(|| TaskRuntimeError::Invalid("parent task is not in the project".to_owned()))?; + let mut cursor = Some(parent); + while let Some(item) = cursor { + if moving_task_id == Some(item.id.as_str()) { + return Err(TaskRuntimeError::Invalid( + "task hierarchy cannot contain a cycle".to_owned(), + )); + } + cursor = match item.parent_id { + Some(parent_id) => get_item_from(connection, &parent_id, "task")?, + None => None, + }; + } + Ok(()) +} + +fn normalize_project_key(value: Option, name: &str) -> String { + let normalized = value.unwrap_or_else(|| { + name.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .take(8) + .collect() + }); + let normalized = normalized.trim().to_ascii_uppercase(); + if normalized.len() >= 2 { + normalized.chars().take(16).collect() + } else { + format!("PRJ{}", &Uuid::new_v4().simple().to_string()[..6]).to_ascii_uppercase() + } +} + +fn validate_name(value: &str, label: &str) -> Result<(), TaskRuntimeError> { + let length = value.trim().chars().count(); + if length == 0 || length > 255 { + return Err(TaskRuntimeError::Invalid(format!( + "{label} must contain 1 to 255 characters" + ))); + } + Ok(()) +} + +fn validate_status(value: &str) -> Result<(), TaskRuntimeError> { + matches!( + value, + "inbox" | "pending" | "in_progress" | "in_review" | "completed" + ) + .then_some(()) + .ok_or_else(|| TaskRuntimeError::Invalid("invalid task status".to_owned())) +} + +fn validate_priority(value: &str) -> Result<(), TaskRuntimeError> { + matches!(value, "none" | "low" | "medium" | "high" | "urgent") + .then_some(()) + .ok_or_else(|| TaskRuntimeError::Invalid("invalid task priority".to_owned())) +} + +fn local_database_path() -> PathBuf { + let home = env::var_os("WEGENT_EXECUTOR_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".wegent-executor"))) + .unwrap_or_else(|| PathBuf::from(".wegent-executor")); + home.join("data").join("tasks.sqlite") +} + +pub(crate) fn now() -> String { + Utc::now().to_rfc3339() +} + +pub(crate) fn numeric_id() -> String { + let prefix = &Uuid::new_v4().simple().to_string()[..13]; + u64::from_str_radix(prefix, 16) + .expect("UUID prefix should be hexadecimal") + .to_string() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use tempfile::TempDir; + + use super::*; + use crate::task_runtime::{BinaryInput, DeliveryCreate}; + + fn store() -> (TempDir, LocalTaskStore) { + let directory = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(directory.path().join("tasks.sqlite")).unwrap(); + (directory, store) + } + + fn local_project(store: &LocalTaskStore) -> LoopItem { + store + .create_project(ProjectCreate { + name: "Local board".to_owned(), + project_key: Some("LOCAL".to_owned()), + description: "Local project".to_owned(), + task_provider: TaskProviderKind::Local, + provider_config: json!({}), + }) + .unwrap() + } + + #[test] + fn creates_the_backend_compatible_single_table_schema() { + let (_directory, store) = store(); + let connection = store.connection().unwrap(); + let columns = connection + .prepare("PRAGMA table_info(loop_items)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + for required in [ + "id", + "resource_type", + "cloud_project_id", + "parent_id", + "loop_item_id", + "delivery_id", + "metadata", + "version", + "deleted_at", + ] { + assert!(columns.iter().any(|column| column == required)); + } + } + + #[test] + fn caches_backend_external_projects_without_exposing_credentials() { + let (_directory, store) = store(); + let configured = store + .configure_external_project(ProjectDescriptor { + id: "cloud-1".to_owned(), + public_id: Some("public-1".to_owned()), + project_key: "CLOUD".to_owned(), + name: "Cloud GitHub board".to_owned(), + description: String::new(), + project_store: ProjectStoreKind::Backend, + task_provider: TaskProviderKind::Github, + provider_config: json!({ + "repository": "acme/repo", + "token": "local-secret", + }), + version: 1, + }) + .unwrap(); + + assert_eq!(configured.id, "cloud-1"); + let projects = store.list_projects().unwrap(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, "cloud-1"); + assert_eq!( + projects[0].metadata["task_provider"], + json!(TaskProviderKind::Github) + ); + let serialized = store + .connection() + .unwrap() + .query_row( + "SELECT provider_config FROM project_provider_credentials + WHERE project_store = 'backend' AND project_id = 'cloud-1'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(); + assert!(!serialized.contains("local-secret")); + let descriptor = store + .connection() + .unwrap() + .query_row( + "SELECT descriptor FROM external_project_catalog + WHERE project_store = 'backend' AND project_id = 'cloud-1'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(); + assert!(!descriptor.contains("local-secret")); + assert_eq!( + configured.metadata["project_store"], + json!(ProjectStoreKind::Backend) + ); + assert_eq!( + store.get_project("cloud-1").unwrap().name.as_deref(), + Some("Cloud GitHub board") + ); + } + + #[test] + fn creates_nested_tasks_and_rejects_cycles() { + let (_directory, store) = store(); + let project = local_project(&store); + let parent = store + .create_task( + &project.id, + TaskCreate { + title: "Parent".to_owned(), + description: String::new(), + status: "inbox".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec![], + }, + ) + .unwrap(); + let child = store + .create_task( + &project.id, + TaskCreate { + title: "Child".to_owned(), + description: String::new(), + status: "pending".to_owned(), + priority: "high".to_owned(), + parent_id: Some(parent.id.clone()), + tags: vec!["nested".to_owned()], + }, + ) + .unwrap(); + + assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str())); + let error = store + .update_task( + &project.id, + &parent.id, + TaskUpdate { + version: parent.version, + parent_id: Some(Some(child.id)), + ..TaskUpdate::default() + }, + ) + .unwrap_err(); + assert!(matches!(error, TaskRuntimeError::Invalid(_))); + } + + #[test] + fn reorders_one_local_board_lane() { + let (_directory, store) = store(); + let project = local_project(&store); + let first = store + .create_task( + &project.id, + TaskCreate { + title: "First".to_owned(), + description: String::new(), + status: "inbox".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec![], + }, + ) + .unwrap(); + let second = store + .create_task( + &project.id, + TaskCreate { + title: "Second".to_owned(), + description: String::new(), + status: "inbox".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec![], + }, + ) + .unwrap(); + + let reordered = store + .reorder_tasks( + &project.id, + TaskReorder { + parent_id: None, + status: "inbox".to_owned(), + item_ids: vec![second.id.clone(), first.id.clone()], + }, + ) + .unwrap(); + + assert_eq!( + reordered + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + vec![second.id.as_str(), first.id.as_str()] + ); + assert_eq!(reordered[0].sort_order, 0); + assert_eq!(reordered[1].sort_order, 1); + } + + #[test] + fn binds_and_unbinds_a_runtime_task() { + let (_directory, store) = store(); + let project = local_project(&store); + let task = store + .create_task( + &project.id, + TaskCreate { + title: "Bound task".to_owned(), + description: String::new(), + status: "inbox".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec![], + }, + ) + .unwrap(); + + let binding = store + .bind_task( + &project.id, + Some(&task.id), + None, + RuntimeTaskAddress { + device_id: "local-device".to_owned(), + task_id: "runtime-1".to_owned(), + task_title: Some("Runtime".to_owned()), + backend_task_id: None, + }, + ) + .unwrap(); + + assert_eq!(binding.loop_item_id.as_deref(), Some(task.id.as_str())); + assert_eq!(store.list_task_bindings(&task.id).unwrap().len(), 1); + assert_eq!( + store + .get_task(&project.id, &task.id) + .unwrap() + .status + .as_deref(), + Some("in_progress") + ); + store.unbind_task("local-device", "runtime-1").unwrap(); + assert!(matches!( + store.find_task_binding("local-device", "runtime-1"), + Err(TaskRuntimeError::TaskNotFound) + )); + } + + #[test] + fn stores_project_content_without_persisting_external_issues() { + let (_directory, store) = store(); + let local_project = local_project(&store); + let task = store + .create_task( + &local_project.id, + TaskCreate { + title: "Persisted".to_owned(), + description: String::new(), + status: "inbox".to_owned(), + priority: "none".to_owned(), + parent_id: None, + tags: vec![], + }, + ) + .unwrap(); + + let file = store + .upload_project_file( + &local_project.id, + Some("docs/readme.txt"), + BinaryInput { + display_name: "readme.txt".to_owned(), + content_type: Some("text/plain".to_owned()), + base64: "aGVsbG8=".to_owned(), + }, + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(store.project_file_path(&file.id).unwrap()).unwrap(), + "hello" + ); + let object_key: String = store + .connection() + .unwrap() + .query_row( + "SELECT object_key FROM loop_items WHERE id = ?1", + [&file.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(object_key, format!("files/{}/readme.txt", file.id)); + assert!(!std::path::Path::new(&object_key).is_absolute()); + let moved = store + .move_project_file(&file.id, "docs/guides/readme.txt", file.version) + .unwrap(); + assert_eq!(moved.path, "docs/guides/readme.txt"); + + let attachment = store + .add_task_attachment( + &local_project.id, + &task.id, + true, + BinaryInput { + display_name: "notes.txt".to_owned(), + content_type: None, + base64: "bm90ZXM=".to_owned(), + }, + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(store.task_attachment_path(&attachment.id).unwrap()).unwrap(), + "notes" + ); + + let delivery = store + .create_delivery( + &local_project.id, + &task.id, + true, + DeliveryCreate { + markdown: "# Done".to_owned(), + chat: None, + source_task: None, + }, + ) + .unwrap(); + let asset = store + .add_delivery_asset( + &delivery.id, + "assets/result.txt", + BinaryInput { + display_name: "result.txt".to_owned(), + content_type: None, + base64: "ZG9uZQ==".to_owned(), + }, + ) + .unwrap(); + let finalized = store.finalize_delivery(&task.id, &delivery.id).unwrap(); + assert_eq!(finalized.status, "delivered"); + assert_eq!( + std::fs::read_to_string(store.delivery_asset_path(&asset.id).unwrap()).unwrap(), + "done" + ); + assert_eq!(store.list_deliveries(&task.id).unwrap().len(), 1); + + let external_project = store + .create_project(ProjectCreate { + name: "GitHub board".to_owned(), + project_key: Some("GH".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Github, + provider_config: json!({"repository": "owner/repo"}), + }) + .unwrap(); + store + .add_task_attachment( + &external_project.id, + "github-42", + false, + BinaryInput { + display_name: "issue.txt".to_owned(), + content_type: None, + base64: "aXNzdWU=".to_owned(), + }, + ) + .unwrap(); + let external_delivery = store + .create_delivery( + &external_project.id, + "42", + false, + DeliveryCreate { + markdown: "External result".to_owned(), + chat: None, + source_task: None, + }, + ) + .unwrap(); + let external_asset = store + .add_delivery_asset( + &external_delivery.id, + "result.txt", + BinaryInput { + display_name: "result.txt".to_owned(), + content_type: None, + base64: "ZG9uZQ==".to_owned(), + }, + ) + .unwrap(); + let connection = store.connection().unwrap(); + let task_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM loop_items WHERE resource_type = 'task' + AND cloud_project_id = ?1", + [&external_project.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(task_count, 0); + let (loop_item_id, external_item_id): (Option, Option) = connection + .query_row( + "SELECT loop_item_id, json_extract(metadata, '$.external_item_id') + FROM loop_items WHERE id = ?1", + [&external_asset.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(loop_item_id, None); + assert_eq!(external_item_id.as_deref(), Some("42")); + } + + #[test] + fn external_provider_projects_do_not_create_local_task_rows() { + let (_directory, store) = store(); + let project = store + .create_project(ProjectCreate { + name: "GitHub board".to_owned(), + project_key: Some("GH".to_owned()), + description: String::new(), + task_provider: TaskProviderKind::Github, + provider_config: json!({"repository": "owner/repo"}), + }) + .unwrap(); + + let error = store.list_tasks(&project.id).unwrap_err(); + assert!(matches!(error, TaskRuntimeError::UnsupportedProvider(_))); + let connection = store.connection().unwrap(); + let task_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM loop_items WHERE resource_type = 'task'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(task_count, 0); + } +} diff --git a/executor/tests/local_app_ipc_contract.rs b/executor/tests/local_app_ipc_contract.rs index aebed1fc48..768bdd9a56 100644 --- a/executor/tests/local_app_ipc_contract.rs +++ b/executor/tests/local_app_ipc_contract.rs @@ -129,6 +129,362 @@ async fn app_ipc_routes_codex_app_server_request() { ); } +#[tokio::test] +async fn app_ipc_manages_local_projects_and_nested_todos() { + let _lock = env_lock().await; + let executor_home = tempfile::tempdir().unwrap(); + let _executor_home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &executor_home.path().display().to_string(), + ); + let server = AppIpcServer::new(); + + let project = server + .dispatch( + "projects.create", + json!({ + "name": "Local Work", + "project_key": "LOCAL", + "description": "Stored by Executor", + "task_provider": "local" + }), + ) + .await + .unwrap(); + let project_id = project["id"].as_str().unwrap(); + let updated_project = server + .dispatch( + "projects.update", + json!({ + "project_id": project_id, + "project": { + "version": project["version"], + "name": "Renamed Local Work", + "tags": ["desktop"] + } + }), + ) + .await + .unwrap(); + assert_eq!(updated_project["name"], "Renamed Local Work"); + assert_eq!(updated_project["metadata"]["tags"], json!(["desktop"])); + + let parent = server + .dispatch( + "todos.create", + json!({ + "project_id": project_id, + "todo": { + "title": "Parent", + "status": "inbox", + "priority": "high" + } + }), + ) + .await + .unwrap(); + let parent_id = parent["id"].as_str().unwrap(); + + let child = server + .dispatch( + "todos.create", + json!({ + "project_id": project_id, + "todo": { + "title": "Child", + "parent_id": parent_id + } + }), + ) + .await + .unwrap(); + + let todos = server + .dispatch("todos.list", json!({"project_id": project_id})) + .await + .unwrap(); + assert_eq!(todos.as_array().unwrap().len(), 2); + assert_eq!(child["parent_id"], parent["id"]); + + let updated = server + .dispatch( + "todos.update", + json!({ + "project_id": project_id, + "task_id": parent_id, + "todo": { + "version": parent["version"], + "status": "completed" + } + }), + ) + .await + .unwrap(); + assert_eq!(updated["status"], "completed"); + assert!(updated["completed_at"].is_string()); + + let conflict = server + .dispatch( + "todos.update", + json!({ + "project_id": project_id, + "task_id": parent_id, + "todo": { + "version": parent["version"], + "title": "Stale update" + } + }), + ) + .await + .unwrap_err(); + assert_eq!(conflict.code, "version_conflict"); + assert!(executor_home.path().join("data/tasks.sqlite").is_file()); +} + +#[tokio::test] +async fn app_ipc_stores_project_files_attachments_and_deliveries_locally() { + let _lock = env_lock().await; + let executor_home = tempfile::tempdir().unwrap(); + let _executor_home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &executor_home.path().display().to_string(), + ); + let server = AppIpcServer::new(); + + let project = server + .dispatch( + "projects.create", + json!({ + "name": "Content", + "project_key": "LOCAL", + "task_provider": "local" + }), + ) + .await + .unwrap(); + let project_id = project["id"].as_str().unwrap(); + let task = server + .dispatch( + "todos.create", + json!({"project_id": project_id, "todo": {"title": "Write report"}}), + ) + .await + .unwrap(); + let task_id = task["id"].as_str().unwrap(); + + let uploaded = server + .dispatch( + "files.upload", + json!({ + "project_id": project_id, + "path": "docs/readme.txt", + "file": { + "display_name": "readme.txt", + "content_type": "text/plain", + "base64": "aGVsbG8=" + } + }), + ) + .await + .unwrap(); + let file_id = uploaded["id"].as_str().unwrap(); + let access = server + .dispatch("files.access", json!({"file_id": file_id})) + .await + .unwrap(); + assert_eq!( + fs::read_to_string(access["path"].as_str().unwrap()).unwrap(), + "hello" + ); + + let moved = server + .dispatch( + "files.move", + json!({ + "file_id": file_id, + "path": "docs/guides/readme.txt", + "version": uploaded["version"] + }), + ) + .await + .unwrap(); + assert_eq!(moved["path"], "docs/guides/readme.txt"); + + let attachment = server + .dispatch( + "attachments.add", + json!({ + "project_id": project_id, + "item_id": task_id, + "file": { + "display_name": "notes.txt", + "base64": "bm90ZXM=" + } + }), + ) + .await + .unwrap(); + let attachment_id = attachment["id"].as_str().unwrap(); + let attachment_access = server + .dispatch( + "attachments.access", + json!({"attachment_id": attachment_id}), + ) + .await + .unwrap(); + assert_eq!( + fs::read_to_string(attachment_access["path"].as_str().unwrap()).unwrap(), + "notes" + ); + + let delivery = server + .dispatch( + "deliveries.create", + json!({ + "project_id": project_id, + "item_id": task_id, + "delivery": { + "markdown": "# Result", + "chat": {"messages": []} + } + }), + ) + .await + .unwrap(); + let delivery_id = delivery["id"].as_str().unwrap(); + let asset = server + .dispatch( + "deliveries.add_asset", + json!({ + "delivery_id": delivery_id, + "relative_path": "assets/result.txt", + "file": { + "display_name": "result.txt", + "base64": "ZG9uZQ==" + } + }), + ) + .await + .unwrap(); + let asset_id = asset["id"].as_str().unwrap(); + let finalized = server + .dispatch( + "deliveries.finalize", + json!({"item_id": task_id, "delivery_id": delivery_id}), + ) + .await + .unwrap(); + assert_eq!(finalized["status"], "delivered"); + + let detail = server + .dispatch("deliveries.get", json!({"delivery_id": delivery_id})) + .await + .unwrap(); + assert_eq!(detail["markdown"], "# Result"); + assert_eq!(detail["chat"]["messages"].as_array().unwrap().len(), 0); + + let asset_access = server + .dispatch("deliveries.access_asset", json!({"asset_id": asset_id})) + .await + .unwrap(); + assert_eq!( + fs::read_to_string(asset_access["path"].as_str().unwrap()).unwrap(), + "done" + ); + assert!(executor_home.path().join("data/objects").is_dir()); +} + +#[tokio::test] +async fn app_ipc_encrypts_provider_credentials_and_masks_project_responses() { + let _lock = env_lock().await; + let executor_home = tempfile::tempdir().unwrap(); + let _executor_home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &executor_home.path().display().to_string(), + ); + let server = AppIpcServer::new(); + + let project = server + .dispatch( + "projects.create", + json!({ + "name": "GitHub board", + "project_key": "GH", + "task_provider": "github", + "provider_config": { + "repository": "acme/repo", + "domain": "github.com", + "token": "github-secret" + } + }), + ) + .await + .unwrap(); + + assert_eq!( + project["metadata"]["provider_config"]["credential_configured"], + true + ); + assert!(project["metadata"]["provider_config"] + .get("credential") + .is_none()); + assert!(!project.to_string().contains("github-secret")); + + let connection = + rusqlite::Connection::open(executor_home.path().join("data/tasks.sqlite")).unwrap(); + let metadata: String = connection + .query_row( + "SELECT metadata FROM loop_items WHERE id = ?1", + [project["id"].as_str().unwrap()], + |row| row.get(0), + ) + .unwrap(); + assert!(!metadata.contains("github-secret")); + let metadata: serde_json::Value = serde_json::from_str(&metadata).unwrap(); + assert_eq!( + metadata["provider_config"]["credential"]["algorithm"], + "aes-256-gcm" + ); + assert!(metadata["provider_config"]["credential"]["ciphertext"].is_string()); + assert!(executor_home + .path() + .join("credentials/provider-master-key-v1") + .is_file()); + + let project = server + .dispatch( + "projects.update", + json!({ + "project_id": project["id"], + "project": { + "version": project["version"], + "provider_config": { + "repository": "acme/repo", + "domain": "github.com", + "token": "rotated-secret" + } + } + }), + ) + .await + .unwrap(); + assert_eq!( + project["metadata"]["provider_config"]["credential_configured"], + true + ); + assert!(!project.to_string().contains("rotated-secret")); + + let metadata: String = connection + .query_row( + "SELECT metadata FROM loop_items WHERE id = ?1", + [project["id"].as_str().unwrap()], + |row| row.get(0), + ) + .unwrap(); + assert!(!metadata.contains("github-secret")); + assert!(!metadata.contains("rotated-secret")); +} + #[tokio::test] async fn app_ipc_emits_runtime_events_with_device_id() { let server = AppIpcServer::new().with_device_id("device-1"); @@ -809,6 +1165,55 @@ async fn app_ipc_accepts_gitdir_with_configured_worktree_as_worktree_source() { let _ = fs::remove_dir_all(root); } +#[tokio::test] +async fn app_ipc_routes_external_project_configuration() { + let _lock = env_lock().await; + let executor_home = tempfile::tempdir().unwrap(); + let _executor_home = EnvGuard::set( + "WEGENT_EXECUTOR_HOME", + &executor_home.path().display().to_string(), + ); + let server = AppIpcServer::new(); + + let response = server + .handle_line( + &json!({ + "type": "request", + "id": "req-external-project", + "method": "external_projects.configure", + "params": { + "project": { + "id": "cloud-1", + "public_id": "public-1", + "project_key": "CLOUD", + "name": "Cloud GitLab board", + "project_store": "backend", + "task_provider": "gitlab", + "provider_config": { + "repository": "acme/repo", + "domain": "gitlab.example.com", + "api_base": "https://gitlab.example.com/api/v4", + "token": "gitlab-secret" + }, + "version": 1 + } + } + }) + .to_string(), + ) + .await + .unwrap(); + + assert_eq!(response["ok"], true); + assert_eq!(response["result"]["id"], "cloud-1"); + assert_eq!(response["result"]["metadata"]["task_provider"], "gitlab"); + assert_eq!( + response["result"]["metadata"]["provider_config"]["credential_configured"], + true + ); + assert!(!response.to_string().contains("gitlab-secret")); +} + #[tokio::test] async fn app_ipc_health_check_confirms_bidirectional_transport() { let server = AppIpcServer::new(); diff --git a/executor/tests/local_task_mcp_contract.rs b/executor/tests/local_task_mcp_contract.rs new file mode 100644 index 0000000000..2fa32a723b --- /dev/null +++ b/executor/tests/local_task_mcp_contract.rs @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2026 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use axum::{http::HeaderMap, routing::post, Json, Router}; +use serde_json::json; +use serde_json::Value; +use wegent_executor::task_runtime::{ + LocalTaskStore, ProjectDescriptor, ProjectStoreKind, TaskProviderKind, TaskRuntime, +}; + +#[test] +fn task_mcp_runs_over_stdio_without_listening_on_a_port() { + let executor_home = tempfile::tempdir().unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_wegent-executor")) + .arg("task-mcp-server") + .env("WEGENT_EXECUTOR_HOME", executor_home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{}}}}"# + ) + .unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"# + ) + .unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{{"name":"create_project","arguments":{{"name":"GitHub board","project_key":"GH","task_provider":"github","provider_config":{{"repository":"acme/repo","token":"mcp-secret"}}}}}}}}"# + ) + .unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let responses = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + + assert_eq!( + responses[0].pointer("/result/serverInfo/name"), + Some(&Value::String("wegent_tasks".to_owned())) + ); + let tools = responses[1] + .pointer("/result/tools") + .and_then(Value::as_array) + .unwrap(); + assert!(tools.iter().any(|tool| tool["name"] == "list_projects")); + assert!(tools.iter().any(|tool| tool["name"] == "update_project")); + assert!(tools.iter().any(|tool| tool["name"] == "create_todo")); + assert!(tools.iter().any(|tool| tool["name"] == "update_todo")); + assert!(tools.iter().any(|tool| tool["name"] == "add_todo_comment")); + assert!(executor_home.path().join("data/tasks.sqlite").is_file()); + let project = responses[2] + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap_or_else(|| panic!("unexpected create_todo response: {}", responses[1])); + assert_eq!( + project["metadata"]["provider_config"]["credential_configured"], + true + ); + assert!(!responses[2].to_string().contains("mcp-secret")); + + let connection = + rusqlite::Connection::open(executor_home.path().join("data/tasks.sqlite")).unwrap(); + let metadata: String = connection + .query_row( + "SELECT metadata FROM loop_items WHERE resource_type = 'project'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(!metadata.contains("mcp-secret")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task_mcp_routes_cached_cloud_gitlab_projects_to_gitlab() { + async fn create_issue(headers: HeaderMap, Json(body): Json) -> Json { + assert_eq!(headers.get("private-token").unwrap(), "gitlab-secret"); + assert_eq!(body["title"], "Created through MCP"); + Json(json!({ + "iid": 11, + "title": "Created through MCP", + "description": "", + "state": "opened", + "web_url": "https://gitlab.example/acme/repo/-/issues/11", + "author": {"username": "tester"}, + "labels": ["wegent:status:inbox"], + "user_notes_count": 0, + "created_at": "2026-07-27T08:00:00Z", + "updated_at": "2026-07-27T08:00:00Z", + "closed_at": null + })) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/projects/12/issues", post(create_issue)), + ) + .await + .unwrap(); + }); + let executor_home = tempfile::tempdir().unwrap(); + let store = LocalTaskStore::open(executor_home.path().join("data/tasks.sqlite")).unwrap(); + TaskRuntime::new(store.clone()) + .unwrap() + .configure_external_project(ProjectDescriptor { + id: "9001".to_owned(), + public_id: Some("cloud-gitlab".to_owned()), + project_key: "GLABC".to_owned(), + name: "Cloud GitLab".to_owned(), + description: String::new(), + project_store: ProjectStoreKind::Backend, + task_provider: TaskProviderKind::Gitlab, + provider_config: json!({ + "repository": "12", + "domain": "127.0.0.1", + "api_base": format!("http://{address}"), + "token": "gitlab-secret" + }), + version: 1, + }) + .unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_wegent-executor")) + .arg("task-mcp-server") + .env("WEGENT_EXECUTOR_HOME", executor_home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"list_projects","arguments":{{}}}}}}"# + ) + .unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_todo","arguments":{{"project_id":"9001","todo":{{"title":"Created through MCP"}}}}}}}}"# + ) + .unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let responses = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let projects = responses[0] + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap(); + assert_eq!(projects[0]["id"], "9001"); + assert_eq!(projects[0]["metadata"]["project_store"], "backend"); + assert_eq!(projects[0]["metadata"]["task_provider"], "gitlab"); + let create_response = responses[1] + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .unwrap(); + assert_eq!( + responses[1].pointer("/result/isError"), + Some(&Value::Bool(true)) + ); + assert!(create_response.contains("provider api_base must use HTTPS")); + + let task_count: i64 = + rusqlite::Connection::open(executor_home.path().join("data/tasks.sqlite")) + .unwrap() + .query_row( + "SELECT COUNT(*) FROM loop_items WHERE resource_type = 'task'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(task_count, 0); + server.abort(); +} diff --git a/wework/src-tauri/tauri.conf.json b/wework/src-tauri/tauri.conf.json index ca2b9a003c..c5908247fd 100644 --- a/wework/src-tauri/tauri.conf.json +++ b/wework/src-tauri/tauri.conf.json @@ -37,9 +37,7 @@ "enable": true, "scope": { "requireLiteralLeadingDot": false, - "allow": [ - "**/*" - ] + "allow": ["**/*"] } } } @@ -55,13 +53,8 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": [ - "binaries/wegent-executor" - ], - "resources": [ - "binaries/codex/**/*", - "bundled-hooks/**/*" - ], + "externalBin": ["binaries/wegent-executor"], + "resources": ["binaries/codex/**/*", "bundled-hooks/**/*"], "android": { "debugApplicationIdSuffix": ".debug" } diff --git a/wework/src/api/backend/backendServices.ts b/wework/src/api/backend/backendServices.ts index c9eb3fb990..dda17fe246 100644 --- a/wework/src/api/backend/backendServices.ts +++ b/wework/src/api/backend/backendServices.ts @@ -47,6 +47,7 @@ export function createBackendWorkbenchServices( const projectApi = createProjectApi(client) const runtimeWorkApi = createRuntimeWorkApi(client) const taskApi = createTaskApi(client) + const deliveryApi = createDeliveryApi(client) const socketClient = createSocketClient({ socketBaseUrl: () => socketBaseUrl, path: socketPath, @@ -64,7 +65,11 @@ export function createBackendWorkbenchServices( gitApi: createGitApi(client), taskApi, deviceApi, - deliveryApi: createDeliveryApi(client), + deliveryApi, + projectSpaceApis: { + cloud: deliveryApi, + defaultLocation: 'cloud', + }, imSessionApi: createImSessionApi(client), runtimeWorkApi, executorClient: createExecutorClientFromApis({ diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index 00aae6cefb..143b8327ec 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -75,6 +75,14 @@ export interface CloudProject { project_key: string name: string description: string + project_store: 'local' | 'backend' + task_provider: 'local' | 'github' | 'gitlab' + provider_config: { + repository?: string + domain?: string + api_base?: string + credential_configured?: boolean + } created_by_user_id: number status: string tags: string[] @@ -177,15 +185,31 @@ export function createDeliveryApi(client: HttpClient) { project_key?: string name: string description?: string + task_provider?: 'local' | 'github' | 'gitlab' + provider_config?: { + repository?: string + domain?: string + api_base?: string + token?: string + } }): Promise { return client.post('/v1/cloud-projects', data) }, + getCloudProjectProviderCredential(projectId: CloudProjectIdInput): Promise<{ token: string }> { + return client.get(`/v1/cloud-projects/${projectId}/provider-credential`) + }, updateCloudProject( projectId: CloudProjectIdInput, data: { name?: string description?: string tags?: string[] + provider_config?: { + repository?: string + domain?: string + api_base?: string + token?: string + } version: number } ): Promise { diff --git a/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts b/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts new file mode 100644 index 0000000000..267d001ac4 --- /dev/null +++ b/wework/src/api/hybrid/cloudProjectSpaceApi.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test, vi } from 'vitest' +import type { CloudProject } from '@/api/deliveries' +import type { DeliveryApi, ExternalIssueApi } from '@/features/workbench/workbenchServices' +import { createCloudProjectSpaceApi } from './cloudProjectSpaceApi' + +const project: CloudProject = { + id: 'cloud-1', + public_id: 'public-1', + project_key: 'CLOUD', + name: 'Cloud GitHub board', + description: '', + project_store: 'backend', + task_provider: 'github', + provider_config: { repository: 'acme/repo' }, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-07-27T00:00:00Z', + updated_at: '2026-07-27T00:00:00Z', +} + +describe('cloud project-space API', () => { + test('stores the token in backend and configures the local executor', async () => { + const storeApi = { + createCloudProject: vi.fn(async () => project), + } as unknown as DeliveryApi + const externalIssueApi = { + configureProject: vi.fn(async () => undefined), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await api.createCloudProject({ + name: project.name, + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + token: 'local-secret', + }, + }) + + expect(storeApi.createCloudProject).toHaveBeenCalledWith({ + name: project.name, + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + token: 'local-secret', + }, + }) + expect(externalIssueApi.configureProject).toHaveBeenCalledWith(project, 'local-secret') + }) + + test('uses submitted provider routing without waiting for the backend response to echo it', async () => { + const incompatibleProject = { + ...project, + task_provider: 'local' as const, + provider_config: {}, + } + const storeApi = { + createCloudProject: vi.fn(async () => incompatibleProject), + } as unknown as DeliveryApi + const externalIssueApi = { + configureProject: vi.fn(), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + const created = await api.createCloudProject({ + name: project.name, + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + token: 'local-secret', + }, + }) + + expect(created.task_provider).toBe('github') + expect(created.provider_config).toEqual({ repository: 'acme/repo' }) + expect(externalIssueApi.configureProject).toHaveBeenCalledWith( + { + ...incompatibleProject, + project_store: 'backend', + task_provider: 'github', + provider_config: { repository: 'acme/repo' }, + }, + 'local-secret' + ) + }) + + test('stores the project in backend and routes GitHub Issues through local executor', async () => { + const storeApi = { + listCloudProjects: vi.fn(async () => ({ items: [project] })), + getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'cloud-secret' })), + createCloudProject: vi.fn(async () => project), + listLoopItems: vi.fn(), + } as unknown as DeliveryApi + const issue = { + id: 'CLOUD-7', + cloud_project_id: project.id, + title: 'Issue', + } + const externalIssueApi = { + configureProject: vi.fn(async () => undefined), + listLoopItems: vi.fn(async () => ({ items: [issue] })), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await api.listCloudProjects() + const result = await api.listLoopItems(project.id) + const [deliveries, bindings, attachments, collaborators] = await Promise.all([ + api.listDeliveries(issue.id), + api.listTaskBindings(issue.id), + api.listLoopItemAttachments(issue.id), + api.listLoopItemCollaborators(issue.id), + ]) + + expect(result.items).toEqual([issue]) + expect(deliveries.items).toEqual([]) + expect(bindings).toEqual([]) + expect(attachments).toEqual([]) + expect(collaborators).toEqual([]) + expect(externalIssueApi.configureProject).toHaveBeenCalledWith(project, 'cloud-secret') + expect(externalIssueApi.listLoopItems).toHaveBeenCalledWith(project) + expect(storeApi.listLoopItems).not.toHaveBeenCalled() + }) + + test('creates cloud external tasks through the local Issue provider', async () => { + const storeApi = { + listCloudProjects: vi.fn(async () => ({ items: [project] })), + getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'cloud-secret' })), + createLoopItem: vi.fn(), + } as unknown as DeliveryApi + const createdIssue = { + id: 'CLOUD-8', + cloud_project_id: project.id, + title: 'Created in GitHub', + } + const externalIssueApi = { + configureProject: vi.fn(async () => undefined), + createLoopItem: vi.fn(async () => createdIssue), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await api.listCloudProjects() + const result = await api.createLoopItem(project.id, { + title: createdIssue.title, + status: 'pending', + }) + + expect(result).toEqual(createdIssue) + expect(externalIssueApi.createLoopItem).toHaveBeenCalledWith(project, { + title: createdIssue.title, + status: 'pending', + }) + expect(storeApi.createLoopItem).not.toHaveBeenCalled() + }) + + test('updates backend credentials and refreshes the local executor configuration', async () => { + const updatedProject = { + ...project, + provider_config: { + repository: 'acme/repo', + credential_configured: true, + }, + version: 2, + } + const storeApi = { + listCloudProjects: vi.fn(async () => ({ items: [project] })), + getCloudProjectProviderCredential: vi.fn(async () => ({ token: 'rotated-secret' })), + updateCloudProject: vi.fn(async () => updatedProject), + } as unknown as DeliveryApi + const externalIssueApi = { + configureProject: vi.fn(async () => undefined), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await api.listCloudProjects() + vi.clearAllMocks() + const updated = await api.updateCloudProject(project.id, { + version: 1, + provider_config: { + repository: 'acme/repo', + token: 'rotated-secret', + }, + }) + + expect(updated).toEqual(updatedProject) + expect(storeApi.updateCloudProject).toHaveBeenCalledWith(project.id, { + version: 1, + provider_config: { + repository: 'acme/repo', + token: 'rotated-secret', + }, + }) + expect(externalIssueApi.configureProject).toHaveBeenCalledWith(updatedProject, 'rotated-secret') + }) + + test('keeps backend internal tasks on the backend store', async () => { + const internalProject = { ...project, task_provider: 'local' as const } + const storeApi = { + listCloudProjects: vi.fn(async () => ({ items: [internalProject] })), + listLoopItems: vi.fn(async () => ({ items: [] })), + } as unknown as DeliveryApi + const externalIssueApi = { + configureProject: vi.fn(), + listLoopItems: vi.fn(), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await api.listCloudProjects() + await api.listLoopItems(internalProject.id) + + expect(storeApi.listLoopItems).toHaveBeenCalledWith(internalProject.id) + expect(externalIssueApi.listLoopItems).not.toHaveBeenCalled() + }) + + test('loads an old external project without credentials so it can be repaired', async () => { + const projectWithoutCredential = { + ...project, + provider_config: { + repository: 'acme/repo', + credential_configured: false, + }, + } + const storeApi = { + listCloudProjects: vi.fn(async () => ({ items: [projectWithoutCredential] })), + getCloudProjectProviderCredential: vi.fn(), + } as unknown as DeliveryApi + const externalIssueApi = { + configureProject: vi.fn(), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await expect(api.listCloudProjects()).resolves.toEqual({ + items: [projectWithoutCredential], + }) + expect(storeApi.getCloudProjectProviderCredential).not.toHaveBeenCalled() + expect(externalIssueApi.configureProject).not.toHaveBeenCalled() + }) + + test('keeps cloud projects visible when one provider credential cannot be restored', async () => { + const storeApi = { + listCloudProjects: vi.fn(async () => ({ items: [project] })), + getCloudProjectProviderCredential: vi.fn(async () => { + throw new Error('Provider credential is not configured') + }), + } as unknown as DeliveryApi + const externalIssueApi = { + configureProject: vi.fn(), + } as unknown as ExternalIssueApi + const api = createCloudProjectSpaceApi(storeApi, externalIssueApi) + + await expect(api.listCloudProjects()).resolves.toEqual({ items: [project] }) + expect(externalIssueApi.configureProject).not.toHaveBeenCalled() + }) +}) diff --git a/wework/src/api/hybrid/cloudProjectSpaceApi.ts b/wework/src/api/hybrid/cloudProjectSpaceApi.ts new file mode 100644 index 0000000000..20f87d2d4a --- /dev/null +++ b/wework/src/api/hybrid/cloudProjectSpaceApi.ts @@ -0,0 +1,162 @@ +import type { + CloudLoopItem, + CloudProject, + CloudProjectId, + createDeliveryApi, +} from '@/api/deliveries' +import type { ExternalIssueApi } from '@/features/workbench/workbenchServices' + +type DeliveryApi = ReturnType + +function isExternalProject(project: CloudProject): boolean { + return project.task_provider === 'github' || project.task_provider === 'gitlab' +} + +export function createCloudProjectSpaceApi( + storeApi: DeliveryApi, + externalIssueApi: ExternalIssueApi +): DeliveryApi { + const projects = new Map() + const taskProjects = new Map() + + function rememberProject(project: CloudProject): CloudProject { + projects.set(project.id, project) + return project + } + + function rememberTasks(projectId: CloudProjectId, items: CloudLoopItem[]): void { + for (const item of items) taskProjects.set(item.id, projectId) + } + + function requireProject(projectId: CloudProjectId | number): CloudProject { + const project = projects.get(String(projectId)) + if (!project) throw new Error('Project space must be loaded before its tasks') + return project + } + + function requireTaskProject(itemId: string): CloudProject { + const projectId = taskProjects.get(itemId) + if (!projectId) throw new Error('Task project must be loaded before the task') + return requireProject(projectId) + } + + return { + ...storeApi, + async listCloudProjects() { + const response = await storeApi.listCloudProjects() + await Promise.all( + response.items.map(async project => { + rememberProject(project) + if ( + isExternalProject(project) && + project.provider_config.credential_configured !== false + ) { + let credential + try { + credential = await storeApi.getCloudProjectProviderCredential(project.id) + } catch { + return + } + try { + await externalIssueApi.configureProject(project, credential.token) + } catch { + return + } + } + }) + ) + return response + }, + async createCloudProject(data) { + const { token, ...providerConfig } = data.provider_config ?? {} + const storedProject = await storeApi.createCloudProject({ + ...data, + provider_config: data.provider_config, + }) + const project = rememberProject({ + ...storedProject, + project_store: 'backend', + task_provider: data.task_provider ?? 'local', + provider_config: providerConfig, + }) + if (isExternalProject(project)) { + await externalIssueApi.configureProject(project, token) + } + return project + }, + async updateCloudProject(projectId, data) { + const current = requireProject(projectId) + const updated = await storeApi.updateCloudProject(projectId, data) + const project = rememberProject({ + ...updated, + project_store: current.project_store, + task_provider: current.task_provider, + provider_config: updated.provider_config ?? current.provider_config, + }) + if (isExternalProject(project)) { + const credential = await storeApi.getCloudProjectProviderCredential(project.id) + await externalIssueApi.configureProject(project, credential.token) + } + return project + }, + async listLoopItems(projectId) { + const project = requireProject(projectId) + const response = isExternalProject(project) + ? await externalIssueApi.listLoopItems(project) + : await storeApi.listLoopItems(projectId) + rememberTasks(project.id, response.items) + return response + }, + async getLoopItem(itemId) { + const project = requireTaskProject(itemId) + const item = isExternalProject(project) + ? await externalIssueApi.getLoopItem(project, itemId) + : await storeApi.getLoopItem(itemId) + taskProjects.set(item.id, project.id) + return item + }, + async createLoopItem(projectId, data) { + const project = requireProject(projectId) + const item = isExternalProject(project) + ? await externalIssueApi.createLoopItem(project, data) + : await storeApi.createLoopItem(projectId, data) + taskProjects.set(item.id, project.id) + return item + }, + async updateLoopItem(itemId, data) { + const project = requireTaskProject(itemId) + const item = isExternalProject(project) + ? await externalIssueApi.updateLoopItem(project, itemId, data) + : await storeApi.updateLoopItem(itemId, data) + taskProjects.set(item.id, project.id) + return item + }, + async reorderLoopItems(projectId, data) { + const project = requireProject(projectId) + if (!isExternalProject(project)) { + return storeApi.reorderLoopItems(projectId, data) + } + const response = await externalIssueApi.listLoopItems(project) + rememberTasks(project.id, response.items) + return response + }, + async listDeliveries(itemId) { + return isExternalProject(requireTaskProject(itemId)) + ? { items: [] } + : storeApi.listDeliveries(itemId) + }, + async listTaskBindings(itemId) { + return isExternalProject(requireTaskProject(itemId)) ? [] : storeApi.listTaskBindings(itemId) + }, + async listLoopItemAttachments(itemId) { + return isExternalProject(requireTaskProject(itemId)) + ? [] + : storeApi.listLoopItemAttachments(itemId) + }, + async listLoopItemCollaborators(itemId) { + return isExternalProject(requireTaskProject(itemId)) + ? [] + : storeApi.listLoopItemCollaborators(itemId) + }, + } +} diff --git a/wework/src/api/hybrid/hybridServices.ts b/wework/src/api/hybrid/hybridServices.ts index eb703dd2f9..a42286f3de 100644 --- a/wework/src/api/hybrid/hybridServices.ts +++ b/wework/src/api/hybrid/hybridServices.ts @@ -4,6 +4,7 @@ import { createCloudRuntimeIpcClient } from '@/api/backend/runtimeIpc' import { createExecutorClientFromApis } from '@/api/executorAccess' import { createLocalAppServices, createRuntimeWorkApiFromIpc } from '@/api/local/localServices' import { createRuntimeChatStream } from '@/api/runtime/runtimeChatStream' +import { createCloudProjectSpaceApi } from './cloudProjectSpaceApi' import type { WorkbenchServices } from '@/features/workbench/workbenchServices' import { notifyWorkbenchCloudArchivesChanged, @@ -883,9 +884,18 @@ export function createHybridWorkbenchServices( } }, } + const cloudProjectSpaceApi = createCloudProjectSpaceApi( + cloudServices.deliveryApi!, + localServices.externalIssueApi! + ) return { ...cloudServices, + projectSpaceApis: { + local: localServices.deliveryApi, + cloud: cloudProjectSpaceApi, + defaultLocation: 'cloud', + }, teamApi: localServices.teamApi, skillApi: localServices.skillApi, projectApi: { diff --git a/wework/src/api/local/localDelivery.test.ts b/wework/src/api/local/localDelivery.test.ts new file mode 100644 index 0000000000..35c48aa4fd --- /dev/null +++ b/wework/src/api/local/localDelivery.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, test, vi } from 'vitest' +import { createExternalIssueApi, createLocalDeliveryApi } from './localDelivery' + +const projectRecord = { + id: 'project-1', + resource_type: 'project', + cloud_project_id: null, + parent_id: null, + public_id: 'public-1', + project_key: 'LOCAL', + name: 'Local board', + title: null, + description: '', + sequence_number: null, + status: 'active', + priority: null, + sort_order: 0, + current_delivery_id: null, + metadata: { task_provider: 'local', tags: [] }, + version: 1, + created_at: '2026-07-27T00:00:00Z', + updated_at: '2026-07-27T00:00:00Z', + completed_at: null, +} + +const taskRecord = { + ...projectRecord, + id: 'LOCAL-1', + resource_type: 'task', + cloud_project_id: projectRecord.id, + public_id: null, + project_key: null, + name: null, + title: 'First task', + sequence_number: 1, + status: 'inbox', + priority: 'none', + metadata: { tags: ['local'] }, +} + +describe('local delivery API', () => { + test('queries a backend project provider without creating a local project', async () => { + const request = vi.fn(async (method: string) => { + if (method === 'external_projects.configure') return projectRecord + if (method === 'external_todos.list') return [taskRecord] + throw new Error(`Unexpected method: ${method}`) + }) + const api = createExternalIssueApi(request) + const cloudProject = { + id: 'cloud-1', + public_id: 'cloud-public-1', + project_key: 'CLOUD', + name: 'Cloud GitHub board', + description: '', + project_store: 'backend' as const, + task_provider: 'github' as const, + provider_config: { repository: 'acme/repo' }, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-07-27T00:00:00Z', + updated_at: '2026-07-27T00:00:00Z', + } + + await api.configureProject(cloudProject, 'local-secret') + await api.listLoopItems(cloudProject) + + expect(request).toHaveBeenCalledWith('external_projects.configure', { + project: expect.objectContaining({ + id: 'cloud-1', + project_store: 'backend', + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + token: 'local-secret', + }, + }), + }) + expect(request).toHaveBeenCalledWith('external_todos.list', { + project: expect.objectContaining({ + id: 'cloud-1', + project_store: 'backend', + task_provider: 'github', + }), + }) + expect(request).not.toHaveBeenCalledWith('projects.create', expect.anything()) + }) + + test('passes external provider credentials only through executor IPC', async () => { + const request = vi.fn(async (method: string) => { + if (method === 'projects.create') { + return { + ...projectRecord, + metadata: { + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + credential_configured: true, + }, + }, + } + } + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await api.createCloudProject({ + name: 'GitHub board', + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + token: 'github-secret', + }, + }) + + expect(request).toHaveBeenCalledWith('projects.create', { + name: 'GitHub board', + task_provider: 'github', + provider_config: { + repository: 'acme/repo', + token: 'github-secret', + }, + }) + }) + + test('routes project and task operations through executor IPC', async () => { + const request = vi.fn(async (method: string) => { + if (method === 'projects.list') return [projectRecord] + if (method === 'projects.update') { + return { ...projectRecord, name: 'Renamed board', version: 2 } + } + if (method === 'todos.list') return [taskRecord] + if (method === 'todos.create') return taskRecord + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await expect(api.listCloudProjects()).resolves.toMatchObject({ + items: [{ id: 'project-1', name: 'Local board' }], + }) + await expect(api.listLoopItems('project-1')).resolves.toMatchObject({ + items: [{ id: 'LOCAL-1', title: 'First task', tags: ['local'] }], + }) + await expect( + api.updateCloudProject('project-1', { name: 'Renamed board', version: 1 }) + ).resolves.toMatchObject({ name: 'Renamed board', version: 2 }) + await api.createLoopItem('project-1', { title: 'First task' }) + + expect(request).toHaveBeenCalledWith('todos.create', { + project_id: 'project-1', + todo: { + title: 'First task', + description: '', + status: 'inbox', + priority: 'none', + parent_id: null, + tags: [], + }, + }) + expect(request).toHaveBeenCalledWith('projects.update', { + project_id: 'project-1', + project: { name: 'Renamed board', version: 1 }, + }) + }) + + test('does not expose cached backend projects as local project spaces', async () => { + const backendProjectRecord = { + ...projectRecord, + id: 'cloud-1', + name: 'Cloud GitLab board', + metadata: { + project_store: 'backend', + task_provider: 'gitlab', + provider_config: { repository: 'group/project' }, + }, + } + const request = vi.fn(async (method: string) => { + if (method === 'projects.list') return [projectRecord, backendProjectRecord] + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await expect(api.listCloudProjects()).resolves.toMatchObject({ + items: [{ id: 'project-1', name: 'Local board' }], + }) + }) + + test('remembers task ownership for updates and persists board order', async () => { + const updatedRecord = { ...taskRecord, title: 'Updated', version: 2 } + const request = vi.fn(async (method: string) => { + if (method === 'todos.list') return [taskRecord] + if (method === 'todos.update') return updatedRecord + if (method === 'todos.reorder') return [updatedRecord] + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await api.listLoopItems('project-1') + await expect( + api.updateLoopItem('LOCAL-1', { version: 1, title: 'Updated' }) + ).resolves.toMatchObject({ title: 'Updated', version: 2 }) + await expect( + api.reorderLoopItems('project-1', { + parent_id: null, + status: 'inbox', + item_ids: ['LOCAL-1'], + }) + ).resolves.toMatchObject({ items: [{ id: 'LOCAL-1' }] }) + + expect(request).toHaveBeenCalledWith('todos.update', { + project_id: 'project-1', + task_id: 'LOCAL-1', + todo: { version: 1, title: 'Updated' }, + }) + expect(request).toHaveBeenCalledWith('todos.reorder', { + project_id: 'project-1', + reorder: { + parent_id: null, + status: 'inbox', + item_ids: ['LOCAL-1'], + }, + }) + }) + + test('binds a runtime task through executor storage', async () => { + const request = vi.fn(async (method: string) => { + if (method === 'todos.list') return [taskRecord] + if (method === 'todos.bind') return { id: '1' } + if (method === 'runtime_tasks.context') { + return { + id: '1', + cloud_project_id: 'project-1', + loop_item_id: 'LOCAL-1', + task_user_id: 0, + device_id: 'local-device', + task_id: 'runtime-1', + task_title: 'Runtime', + backend_task_id: null, + linked_at: '2026-07-27T00:00:00Z', + } + } + if (method === 'todos.get') return taskRecord + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + const runtimeTask = { deviceId: 'local-device', taskId: 'runtime-1' } + + await api.listLoopItems('project-1') + await api.bindTask('LOCAL-1', runtimeTask, 'Runtime') + await expect(api.findLoopItemForTask(runtimeTask)).resolves.toMatchObject({ id: 'LOCAL-1' }) + + expect(request).toHaveBeenCalledWith('todos.bind', { + project_id: 'project-1', + item_id: 'LOCAL-1', + task: { + deviceId: 'local-device', + taskId: 'runtime-1', + taskTitle: 'Runtime', + }, + }) + }) + + test('routes local files, attachments, and deliveries through executor IPC', async () => { + const delivery = { + id: 'delivery-1', + loop_item_id: 'LOCAL-1', + created_by_user_id: 0, + source_task_binding_id: null, + source_task_snapshot: null, + status: 'draft', + created_at: '2026-07-27T00:00:00Z', + delivered_at: null, + assets: [], + } + const request = vi.fn(async (method: string) => { + if (method === 'todos.list') return [taskRecord] + if (method === 'files.create_folder') { + return { + id: 'folder-1', + cloud_project_id: 'project-1', + path: 'docs', + name: 'docs', + kind: 'folder', + content_type: null, + size_bytes: 0, + sha256: null, + description: '', + created_by_user_id: 0, + updated_by_user_id: 0, + version: 1, + created_at: '2026-07-27T00:00:00Z', + updated_at: '2026-07-27T00:00:00Z', + } + } + if (method === 'deliveries.create') return delivery + if (method === 'deliveries.get') return { ...delivery, markdown: '# Done', chat: null } + if (method === 'deliveries.finalize') return { ...delivery, status: 'delivered' } + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await api.listLoopItems('project-1') + await expect(api.createCloudFolder('project-1', 'docs')).resolves.toMatchObject({ + id: 'folder-1', + kind: 'folder', + }) + await expect(api.createDelivery('LOCAL-1', { markdown: '# Done' })).resolves.toMatchObject({ + id: 'delivery-1', + }) + await expect(api.finalizeDelivery('delivery-1')).resolves.toMatchObject({ + status: 'delivered', + }) + + expect(request).toHaveBeenCalledWith('files.create_folder', { + project_id: 'project-1', + path: 'docs', + }) + expect(request).toHaveBeenCalledWith('deliveries.create', { + project_id: 'project-1', + item_id: 'LOCAL-1', + delivery: { markdown: '# Done' }, + }) + expect(request).toHaveBeenCalledWith('deliveries.finalize', { + item_id: 'LOCAL-1', + delivery_id: 'delivery-1', + }) + }) +}) diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts new file mode 100644 index 0000000000..888970a151 --- /dev/null +++ b/wework/src/api/local/localDelivery.ts @@ -0,0 +1,558 @@ +import { convertFileSrc } from '@tauri-apps/api/core' + +import type { + CloudLoopItemAttachment, + CloudLoopItem, + CloudProject, + CloudProjectFile, + CloudProjectId, + CloudProjectMember, + Delivery, + DeliveryAsset, + DeliveryCreateInput, + DeliveryDetail, +} from '@/api/deliveries' +import type { WorkbenchServices } from '@/features/workbench/workbenchServices' +import type { RuntimeTaskAddress } from '@/types/api' + +type LocalRequest = ( + method: string, + params?: Record, + deviceId?: string +) => Promise + +interface LocalLoopItemRecord { + id: string + resource_type: 'project' | 'task' | string + cloud_project_id: string | null + parent_id: string | null + public_id: string | null + project_key: string | null + name: string | null + title: string | null + description: string + sequence_number: number | null + status: string | null + priority: string | null + sort_order: number + current_delivery_id: string | null + metadata: Record + version: number + created_at: string + updated_at: string + completed_at: string | null +} + +interface LocalTaskBindingRecord { + id: string + cloud_project_id: string + loop_item_id: string | null + task_user_id: number + device_id: string + task_id: string + task_title: string | null + backend_task_id: number | null + linked_at: string +} + +interface LocalProjectFileRecord { + id: string + cloud_project_id: string + path: string + name: string + kind: 'file' | 'folder' | string + content_type: string | null + size_bytes: number + sha256: string | null + description: string + created_by_user_id: number + updated_by_user_id: number + version: number + created_at: string + updated_at: string +} + +interface LocalAccessRecord { + path: string +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [] +} + +function localProject(record: LocalLoopItemRecord): CloudProject { + const taskProvider = + record.metadata.task_provider === 'github' || record.metadata.task_provider === 'gitlab' + ? record.metadata.task_provider + : 'local' + return { + id: record.id, + public_id: record.public_id ?? record.id, + project_key: record.project_key ?? 'LOCAL', + name: record.name ?? '', + description: record.description, + project_store: 'local', + task_provider: taskProvider, + provider_config: + record.metadata.provider_config && + typeof record.metadata.provider_config === 'object' && + !Array.isArray(record.metadata.provider_config) + ? (record.metadata.provider_config as CloudProject['provider_config']) + : {}, + created_by_user_id: 0, + status: record.status ?? 'active', + tags: stringList(record.metadata.tags), + version: record.version, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + +function externalProjectDescriptor(project: CloudProject, token?: string) { + return { + id: project.id, + public_id: project.public_id, + project_key: project.project_key, + name: project.name, + description: project.description, + project_store: project.project_store, + task_provider: project.task_provider, + provider_config: { + ...project.provider_config, + ...(token?.trim() ? { token: token.trim() } : {}), + }, + version: project.version, + } +} + +export function createExternalIssueApi(request: LocalRequest) { + return { + async configureProject(project: CloudProject, token?: string) { + await request('external_projects.configure', { + project: externalProjectDescriptor(project, token), + }) + }, + async listLoopItems(project: CloudProject) { + const records = await request('external_todos.list', { + project: externalProjectDescriptor(project), + }) + return { items: records.map(localTask) } + }, + async getLoopItem(project: CloudProject, itemId: string) { + const record = await request('external_todos.get', { + project: externalProjectDescriptor(project), + task_id: itemId, + }) + return localTask(record) + }, + async createLoopItem( + project: CloudProject, + data: { + title: string + description?: string + status?: CloudLoopItem['status'] + priority?: CloudLoopItem['priority'] + parent_id?: string | null + tags?: string[] + } + ) { + const record = await request('external_todos.create', { + project: externalProjectDescriptor(project), + todo: { + title: data.title, + description: data.description ?? '', + status: data.status ?? 'inbox', + priority: data.priority ?? 'none', + parent_id: data.parent_id ?? null, + tags: data.tags ?? [], + }, + }) + return localTask(record) + }, + async updateLoopItem( + project: CloudProject, + itemId: string, + data: Record & { version: number } + ) { + const record = await request('external_todos.update', { + project: externalProjectDescriptor(project), + task_id: itemId, + todo: data, + }) + return localTask(record) + }, + } +} + +function localTask(record: LocalLoopItemRecord): CloudLoopItem { + return { + id: record.id, + cloud_project_id: record.cloud_project_id ?? '', + sequence_number: record.sequence_number ?? 0, + parent_id: record.parent_id, + created_by_user_id: 0, + assignee_user_id: null, + title: record.title ?? '', + description: record.description, + status: (record.status ?? 'inbox') as CloudLoopItem['status'], + priority: (record.priority ?? 'none') as CloudLoopItem['priority'], + due_at: null, + tags: stringList(record.metadata.tags), + sort_order: record.sort_order, + current_delivery_id: record.current_delivery_id, + version: record.version, + created_at: record.created_at, + updated_at: record.updated_at, + completed_at: record.completed_at, + } +} + +function localProjectFile(record: LocalProjectFileRecord): CloudProjectFile { + return { + ...record, + kind: record.kind === 'folder' ? 'folder' : 'file', + } +} + +function fileBytesToBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000 + let binary = '' + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + } + return btoa(binary) +} + +async function fileInput(file: File) { + return { + display_name: file.name, + content_type: file.type || null, + base64: fileBytesToBase64(new Uint8Array(await file.arrayBuffer())), + } +} + +function localAccess(record: LocalAccessRecord) { + return { + url: convertFileSrc(record.path), + expires_in_seconds: 0, + } +} + +function unsupported(name: string): never { + throw new Error(`${name} is not available for local projects yet`) +} + +export function createLocalDeliveryApi( + request: LocalRequest +): NonNullable { + const taskProjects = new Map() + + function rememberTasks(projectId: CloudProjectId, records: LocalLoopItemRecord[]) { + for (const record of records) taskProjects.set(record.id, projectId) + } + + async function resolveProjectId(itemId: string): Promise { + const known = taskProjects.get(itemId) + if (known) return known + const projectRecords = await request('projects.list') + const projects = projectRecords.map(localProject) + const prefixMatches = projects.filter(project => itemId.startsWith(`${project.project_key}-`)) + if (prefixMatches.length === 1) return prefixMatches[0].id + for (const project of projects) { + try { + const record = await request('todos.get', { + project_id: project.id, + task_id: itemId, + }) + taskProjects.set(record.id, project.id) + return project.id + } catch { + // Read-only probing is safe when legacy projects reuse a project key. + } + } + throw new Error('Local task not found') + } + + const api = { + async listCloudProjects() { + const records = await request('projects.list') + return { + items: records + .filter(record => record.metadata.project_store !== 'backend') + .map(localProject), + } + }, + async createCloudProject(data: { + project_key?: string + name: string + description?: string + task_provider?: 'local' | 'github' | 'gitlab' + provider_config?: { + repository?: string + domain?: string + api_base?: string + token?: string + } + }) { + const record = await request('projects.create', { + ...data, + task_provider: data.task_provider ?? 'local', + provider_config: data.provider_config ?? {}, + }) + return localProject(record) + }, + async updateCloudProject( + projectId: CloudProjectId, + data: { + name?: string + description?: string + tags?: string[] + version: number + } + ) { + const record = await request('projects.update', { + project_id: projectId, + project: data, + }) + return localProject(record) + }, + async listMyWork() { + return { items: [] } + }, + async listLoopItems(projectId: CloudProjectId) { + const records = await request('todos.list', { + project_id: projectId, + }) + rememberTasks(projectId, records) + return { items: records.map(localTask) } + }, + async getLoopItem(itemId: string) { + const projectId = await resolveProjectId(itemId) + const record = await request('todos.get', { + project_id: projectId, + task_id: itemId, + }) + taskProjects.set(record.id, projectId) + return localTask(record) + }, + async createLoopItem( + projectId: CloudProjectId, + data: { + title: string + description?: string + status?: CloudLoopItem['status'] + priority?: CloudLoopItem['priority'] + due_at?: string + parent_id?: string | null + tags?: string[] + } + ) { + const record = await request('todos.create', { + project_id: projectId, + todo: { + title: data.title, + description: data.description ?? '', + status: data.status ?? 'inbox', + priority: data.priority ?? 'none', + parent_id: data.parent_id ?? null, + tags: data.tags ?? [], + }, + }) + taskProjects.set(record.id, projectId) + return localTask(record) + }, + async updateLoopItem(itemId: string, data: Record & { version: number }) { + const projectId = await resolveProjectId(itemId) + const record = await request('todos.update', { + project_id: projectId, + task_id: itemId, + todo: data, + }) + taskProjects.set(record.id, projectId) + return localTask(record) + }, + async reorderLoopItems( + projectId: CloudProjectId, + data: { + parent_id: string | null + status: CloudLoopItem['status'] + item_ids: string[] + } + ) { + const records = await request('todos.reorder', { + project_id: projectId, + reorder: data, + }) + rememberTasks(projectId, records) + return { items: records.map(localTask) } + }, + async listLoopItemAttachments(itemId: string) { + return request('attachments.list', { + item_id: itemId, + }) + }, + async addLoopItemAttachment(itemId: string, file: File) { + const projectId = await resolveProjectId(itemId) + return request('attachments.add', { + project_id: projectId, + item_id: itemId, + file: await fileInput(file), + }) + }, + async accessLoopItemAttachment(attachmentId: string) { + return localAccess( + await request('attachments.access', { + attachment_id: attachmentId, + }) + ) + }, + async deleteLoopItemAttachment(attachmentId: string) { + await request('attachments.delete', { attachment_id: attachmentId }) + }, + async listTaskBindings(itemId: string) { + const records = await request('todos.bindings', { + task_id: itemId, + }) + return records.map(record => ({ ...record, id: Number(record.id) })) + }, + listLoopItemCollaborators: async () => [], + addLoopItemCollaborator: async () => unsupported('Task collaborators'), + removeLoopItemCollaborator: async () => unsupported('Task collaborators'), + async bindTask(itemId: string, task: RuntimeTaskAddress, taskTitle?: string | null) { + const projectId = await resolveProjectId(itemId) + await request('todos.bind', { + project_id: projectId, + item_id: itemId, + task: { ...task, ...(taskTitle ? { taskTitle } : {}) }, + }) + }, + async bindProjectTask( + projectId: CloudProjectId, + task: RuntimeTaskAddress, + taskTitle?: string | null + ) { + await request('projects.bind_task', { + project_id: projectId, + task: { ...task, ...(taskTitle ? { taskTitle } : {}) }, + }) + }, + async unbindCloudContext(task: RuntimeTaskAddress) { + await request('runtime_tasks.unbind', { + device_id: task.deviceId, + task_id: task.taskId, + }) + }, + async unbindTask(_itemId: string, task: RuntimeTaskAddress) { + await request('runtime_tasks.unbind', { + device_id: task.deviceId, + task_id: task.taskId, + }) + }, + async findLoopItemForTask(task: RuntimeTaskAddress) { + const binding = await request('runtime_tasks.context', { + device_id: task.deviceId, + task_id: task.taskId, + }) + if (!binding.loop_item_id) throw new Error('Runtime task is linked to a project only') + taskProjects.set(binding.loop_item_id, binding.cloud_project_id) + return api.getLoopItem(binding.loop_item_id) + }, + async findCloudContextForTask(task: RuntimeTaskAddress) { + const binding = await request('runtime_tasks.context', { + device_id: task.deviceId, + task_id: task.taskId, + }) + const projectRecords = await request('projects.list') + const projectRecord = projectRecords.find(record => record.id === binding.cloud_project_id) + if (!projectRecord) throw new Error('Local project not found') + const loopItem = binding.loop_item_id ? await api.getLoopItem(binding.loop_item_id) : null + return { + ...binding, + id: binding.id, + project: localProject(projectRecord), + loop_item: loopItem, + } + }, + listLocalBindings: async () => [], + listCloudProjectMembers: async (): Promise => [], + addCloudProjectMember: async () => unsupported('Project members'), + updateCloudProjectMember: async () => unsupported('Project members'), + removeCloudProjectMember: async () => unsupported('Project members'), + searchCloudProjectUsers: async () => ({ users: [], total: 0 }), + addLocalBinding: async () => unsupported('Local bindings'), + async listCloudFiles(projectId: CloudProjectId) { + const records = await request('files.list', { + project_id: projectId, + }) + return { items: records.map(localProjectFile) } + }, + listProjectDeliveryFiles: async () => ({ items: [] }), + async createCloudFolder(projectId: CloudProjectId, path: string) { + const record = await request('files.create_folder', { + project_id: projectId, + path, + }) + return localProjectFile(record) + }, + async uploadCloudFile(projectId: CloudProjectId, file: File, path = file.name) { + const record = await request('files.upload', { + project_id: projectId, + path, + file: await fileInput(file), + }) + return localProjectFile(record) + }, + async accessCloudFile(fileId: string) { + return localAccess(await request('files.access', { file_id: fileId })) + }, + async accessDeliveryFile(assetId: string) { + return localAccess( + await request('deliveries.access_asset', { asset_id: assetId }) + ) + }, + async moveCloudFile(fileId: string, path: string, version: number) { + const record = await request('files.move', { + file_id: fileId, + path, + version, + }) + return localProjectFile(record) + }, + async deleteCloudFile(fileId: string, recursive = false) { + await request('files.delete', { file_id: fileId, recursive }) + }, + async createDelivery(itemId: string, data: DeliveryCreateInput) { + const projectId = await resolveProjectId(itemId) + return request('deliveries.create', { + project_id: projectId, + item_id: itemId, + delivery: data, + }) + }, + async addAsset(deliveryId: string, file: File, relativePath: string) { + return request('deliveries.add_asset', { + delivery_id: deliveryId, + relative_path: relativePath, + file: await fileInput(file), + }) + }, + async finalizeDelivery(deliveryId: string) { + const delivery = await api.getDelivery(deliveryId) + return request('deliveries.finalize', { + item_id: delivery.loop_item_id, + delivery_id: deliveryId, + }) + }, + async discardDraft(deliveryId: string) { + await request('deliveries.discard', { delivery_id: deliveryId }) + }, + async listDeliveries(itemId: string) { + const records = await request('deliveries.list', { item_id: itemId }) + return { items: records } + }, + async getDelivery(deliveryId: string) { + return request('deliveries.get', { delivery_id: deliveryId }) + }, + } + return api as unknown as NonNullable +} diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index 4c79df77fd..113b8faee2 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -1434,8 +1434,8 @@ describe('createLocalAppServices', () => { const payload = request.mock.calls.find(([method]) => method === 'runtime.tasks.create')?.[1] const prompt = payload.executionRequest.prompt as string expect(prompt).toContain('[projectSpaceCapability]') - expect(prompt).toContain('wegent_delivery is a server id, not a callable tool') - expect(prompt).toContain('create_cloud_project') + expect(prompt).toContain('wegent_delivery and wegent_tasks are server ids') + expect(prompt).toContain('Never create or copy a cloud project') expect(prompt).toContain('do not use list_mcp_resources to discover tools') }) diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 794044062e..85689a77be 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -108,6 +108,7 @@ import { import { getLocalProxyUrl } from '@/features/model-settings/localProxySettings' import { createRuntimeChatStream } from '../runtime/runtimeChatStream' import { createLocalAttachmentApi } from './localAttachments' +import { createExternalIssueApi, createLocalDeliveryApi } from './localDelivery' import { LOCAL_USER, saveLocalUserPreferences } from './localSession' import type { KeybindingOverride } from '@/lib/keybindings' import { @@ -1148,9 +1149,12 @@ function messageWithApplicationContext( kind: 'application', value: [ 'The user activated the Wegent project-space capability.', - 'Use the wegent_delivery MCP server for project-space operations.', - 'wegent_delivery is a server id, not a callable tool.', - 'Use list_cloud_projects to list projects and create_cloud_project to create one.', + 'Project storage and task source are independent.', + 'Use wegent_tasks for local project spaces and for GitHub or GitLab Issues, even when the project space is stored in the Backend.', + 'Use wegent_delivery for cloud project metadata, files, deliveries, and Backend-native TODOs only.', + 'wegent_delivery and wegent_tasks are server ids, not callable tools.', + 'List both sources when resolving a project name.', + 'Never create or copy a cloud project merely because a local project is not returned by list_cloud_projects.', 'Use resolve_cloud_reference to resolve cloud:// references.', 'MCP resources describe addressable data; do not use list_mcp_resources to discover tools.', ].join('\n'), @@ -2377,6 +2381,8 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben user: deps.user, } ) as unknown as NonNullable + const deliveryApi = createLocalDeliveryApi(request) + const externalIssueApi = createExternalIssueApi(request) return { teamApi: { @@ -2429,6 +2435,12 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben revertTurnFileChanges: () => cloudConnectionRequired('revertTurnFileChanges'), }, deviceApi, + deliveryApi, + externalIssueApi, + projectSpaceApis: { + local: deliveryApi, + defaultLocation: 'local', + }, runtimeWorkApi, attachmentApi: createLocalAttachmentApi(), executorClient: createExecutorClientFromApis({ diff --git a/wework/src/components/layout/DesktopAppSwitcher.test.tsx b/wework/src/components/layout/DesktopAppSwitcher.test.tsx index 46248a7618..3361a08950 100644 --- a/wework/src/components/layout/DesktopAppSwitcher.test.tsx +++ b/wework/src/components/layout/DesktopAppSwitcher.test.tsx @@ -62,7 +62,8 @@ describe('DesktopAppSwitcher', () => { ).toEqual(['任务使用 AI 解决具体问题', '智能体构建并交付可嵌入业务的云端智能体']) }) - test('keeps Kanban visible but unavailable while disconnected', () => { + test('keeps Kanban navigable while disconnected', () => { + vi.useFakeTimers() experimentalFeatures.enabled = true const onNavigate = vi.fn() render() @@ -73,14 +74,8 @@ describe('DesktopAppSwitcher', () => { '任务使用 AI 解决具体问题' ) const todoOption = screen.getByTestId('app-switcher-option-todo') - expect(todoOption).toBeDisabled() - const todoUnavailableStatus = screen.getByTestId('app-switcher-unavailable-todo') - expect(todoUnavailableStatus).toHaveAccessibleName('连接云端后可用') - fireEvent.mouseEnter(todoUnavailableStatus) - expect(screen.getByRole('tooltip')).toHaveTextContent('连接云端后可用') - fireEvent.mouseLeave(todoUnavailableStatus) - fireEvent.click(todoOption) - expect(onNavigate).not.toHaveBeenCalled() + expect(todoOption).toBeEnabled() + expect(screen.queryByTestId('app-switcher-unavailable-todo')).not.toBeInTheDocument() const wegentOption = screen.getByTestId('app-switcher-option-wegent') expect(wegentOption).not.toHaveClass('opacity-60') expect(within(wegentOption).getByText('智能体')).toBeInTheDocument() @@ -94,8 +89,10 @@ describe('DesktopAppSwitcher', () => { fireEvent.focus(unavailableStatus) expect(screen.getByRole('tooltip')).toHaveTextContent('连接云端后可用') expect(wegentOption).toBeDisabled() - fireEvent.click(wegentOption) - expect(onNavigate).not.toHaveBeenCalled() + + fireEvent.click(todoOption) + act(() => vi.advanceTimersByTime(260)) + expect(onNavigate).toHaveBeenCalledWith('todo') }) test('shows Kanban when experimental features are enabled', () => { diff --git a/wework/src/components/layout/DesktopAppSwitcher.tsx b/wework/src/components/layout/DesktopAppSwitcher.tsx index e693bcecfe..f7085ffa48 100644 --- a/wework/src/components/layout/DesktopAppSwitcher.tsx +++ b/wework/src/components/layout/DesktopAppSwitcher.tsx @@ -136,10 +136,6 @@ export function DesktopAppSwitcher({ key: 'todo', label: t('workbench.app_weloop_label', '看板'), description: t('workbench.app_weloop_description', '用 AI 管理项目的规划、执行与反馈'), - availabilityLabel: cloudConnection?.isConnected - ? undefined - : t('workbench.app_weloop_requires_cloud', '连接云端后可用'), - disabled: !cloudConnection?.isConnected, }) } diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index a72b2d6ad9..6902034a6a 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -1332,6 +1332,17 @@ describe('DesktopWorkbenchLayout', () => { } } + test('opens the local-capable board route while cloud is disconnected', () => { + window.history.pushState({}, '', '/todo') + + render() + + expect(screen.getByTestId('cloud-board-loading')).toBeInTheDocument() + expect( + screen.getByTestId('desktop-workbench-content').closest('[aria-hidden="true"]') + ).toHaveStyle({ display: 'none' }) + }) + test('submits implementation plan confirmation as a user message response', async () => { const onRequestUserInputSubmit = vi.fn().mockResolvedValue(true) diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index abf347e88d..4dc7bd3164 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -36,7 +36,6 @@ import { EMPTY_RUNTIME_TASK_REMINDERS } from '@/features/workbench/runtimeTaskRe import { CloudTodoWorkspace } from '@/features/todo/CloudTodoWorkspace' import { resolveLocalTodoProjects } from '@/features/todo/localTodoProjects' import { WorkbenchBackground } from '@/features/appearance' -import { useOptionalCloudConnection } from '@/features/cloud-connection/useCloudConnection' type ImNotificationDialogMode = { type: 'global' } | { type: 'task'; address: RuntimeTaskAddress } @@ -53,7 +52,6 @@ function getPermanentWorktreeError(error: unknown, fallback: string) { export function DesktopWorkbenchLayout() { const { t } = useTranslation('common') - const cloudConnection = useOptionalCloudConnection() const { logout: onLogout } = useAuth() const { state, @@ -110,7 +108,7 @@ export function DesktopWorkbenchLayout() { ) const initialPath = stripAppBasePath(window.location.pathname) const [currentPath, setCurrentPath] = useState(initialPath) - const todoOpen = currentPath === '/todo' && cloudConnection.isConnected + const todoOpen = currentPath === '/todo' const activeItem = todoOpen ? 'todo' : 'chat' const taskReminders = runtimeTaskReminders ?? EMPTY_RUNTIME_TASK_REMINDERS const createPermanentWorktree = useCallback( diff --git a/wework/src/features/todo/CloudProjectManageView.tsx b/wework/src/features/todo/CloudProjectManageView.tsx index 4506942429..6d092d5eb1 100644 --- a/wework/src/features/todo/CloudProjectManageView.tsx +++ b/wework/src/features/todo/CloudProjectManageView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Check, Pencil, Search, Tag, Trash2, X } from 'lucide-react' +import { Check, GitBranch, LockKeyhole, Pencil, Search, Tag, Trash2, X } from 'lucide-react' import type { CloudLoopItem, CloudProject, @@ -8,6 +8,7 @@ import type { } from '@/api/deliveries' import type { WorkbenchServices } from '@/features/workbench/workbenchServices' import { cn } from '@/lib/utils' +import { repositoryAddress, repositoryProviderConfig } from './projectProviderConfig' type DeliveryApi = NonNullable @@ -39,6 +40,14 @@ export function CloudProjectManageView({ const [renamingTag, setRenamingTag] = useState(null) const [renameValue, setRenameValue] = useState('') const [tagBusy, setTagBusy] = useState(false) + const externalProvider = + project.task_provider === 'github' || project.task_provider === 'gitlab' + ? project.task_provider + : null + const [providerRepository, setProviderRepository] = useState(() => repositoryAddress(project)) + const [providerToken, setProviderToken] = useState('') + const [providerBusy, setProviderBusy] = useState(false) + const [providerSaved, setProviderSaved] = useState(false) useEffect(() => { let active = true @@ -73,6 +82,31 @@ export function CloudProjectManageView({ onProjectUpdated?.(updated) } + async function saveProviderConfig() { + if (!externalProvider || providerBusy) return + setProviderBusy(true) + setProviderSaved(false) + setError(null) + try { + const updated = await api.updateCloudProject(project.id, { + version: projectVersion, + provider_config: { + ...repositoryProviderConfig(providerRepository, externalProvider), + ...(providerToken.trim() ? { token: providerToken.trim() } : {}), + }, + }) + setProjectVersion(updated.version) + setProviderRepository(repositoryAddress(updated)) + setProviderToken('') + setProviderSaved(true) + onProjectUpdated?.(updated) + } catch (cause) { + setError(cause instanceof Error ? cause.message : '保存任务来源失败') + } finally { + setProviderBusy(false) + } + } + async function createTag() { const tag = newTag.trim() if (!tag || tagBusy) return @@ -349,7 +383,7 @@ export function CloudProjectManageView({ data-testid="cloud-project-tag-create-confirm" disabled={!newTag.trim() || tagBusy} onClick={() => void createTag()} - className="h-9 rounded-lg bg-text-primary px-3.5 text-sm font-medium text-background transition hover:opacity-90 disabled:opacity-50" + className="h-9 rounded-lg bg-black px-3.5 text-sm font-medium text-white transition hover:bg-black/90 disabled:cursor-not-allowed disabled:bg-black disabled:text-white" > 新建标签 @@ -437,6 +471,76 @@ export function CloudProjectManageView({ ))} + + {externalProvider && ( +
+

任务来源

+

+ 云端保存项目和访问令牌,本地 Executor 直接读取和更新{' '} + {externalProvider === 'github' ? 'GitHub' : 'GitLab'} Issues。 +

+
+
+ + {externalProvider === 'github' ? 'GitHub' : 'GitLab'} + + {project.provider_config.credential_configured ? '令牌已配置' : '需要配置令牌'} + +
+ + +
+ {providerSaved && 已保存} + +
+
+
+ )} ) diff --git a/wework/src/features/todo/CloudTodoModal.tsx b/wework/src/features/todo/CloudTodoModal.tsx index 4c4e6d8086..b0f758ef25 100644 --- a/wework/src/features/todo/CloudTodoModal.tsx +++ b/wework/src/features/todo/CloudTodoModal.tsx @@ -1,19 +1,31 @@ import type { ReactNode } from 'react' import { X } from 'lucide-react' +import { cn } from '@/lib/utils' interface CloudTodoModalProps { title: string children: ReactNode onClose: () => void + width?: 'default' | 'wide' } -export function CloudTodoModal({ title, children, onClose }: CloudTodoModalProps) { +export function CloudTodoModal({ + title, + children, + onClose, + width = 'default', +}: CloudTodoModalProps) { return (
event.currentTarget === event.target && onClose()} > -
+

{title}

+ ) + })} +
+ + +
+

任务来源

+
+ {( + [ + ['local', ListTodo, '内置任务', location === 'local' ? '保存在本机' : '保存在云端'], + ['github', GitBranch, 'GitHub', '读取 Issues'], + ['gitlab', GitBranch, 'GitLab', '读取 Issues'], + ] as const + ).map(([value, ProviderIcon, label, detail]) => ( + + ))} +
+
+ + {externalProvider && ( +
+ + +
+ )} + +