Skip to content

Commit d5a23be

Browse files
authored
feat: add reusable Studio workspaces (#1004)
1 parent 8333558 commit d5a23be

86 files changed

Lines changed: 2005 additions & 400 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,10 @@ local image or a downloaded network image into the VeFaaS deployment package.
451451

452452
## Environment image builds
453453

454+
Studio 的“工作区”用于组织一组可复用环境。一个工作区可以包含多个环境,同一个环境也可以加入多个工作区;删除工作区只会删除组合关系,不会删除环境。侧边栏只展示“工作区”入口,工作区页面内可在“工作区”和“环境”两个视图之间切换。Agent 的创建与部署仍直接选择具体环境及其构建版本。
455+
456+
工作区元数据保存在与环境相同的 Studio TOS 桶中,路径为 `veadk-studio/v1/workspaces/<owner>/<workspace-id>/summary.json`。接口包括 `/web/workspaces` CRUD,以及 `/web/workspaces/{workspaceId}/environments/{environmentId}` 的添加和移除操作。被工作区引用的环境不能直接删除。
457+
454458
The Studio `环境` page stores each environment definition, generated Dockerfile,
455459
build version, log metadata, and resulting image reference in the private Studio
456460
TOS bucket. Creating or saving an environment starts an asynchronous

frontend/server/environments/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from .repository import TosEnvironmentRepository
2828
from .resources import EnvironmentResourceSettings, StudioEnvironmentCloudGateway
2929
from .routes import mount_environment_routes
30-
from .service import EnvironmentService
30+
from .service import EnvironmentService, WorkspaceReferenceLookup
3131

3232

3333
def create_environment_service(
@@ -36,17 +36,22 @@ def create_environment_service(
3636
resolve_credentials: CredentialResolver | None = None,
3737
client_factory: Callable[[], Any] | None = None,
3838
environment: Mapping[str, str] | None = None,
39+
workspace_references: WorkspaceReferenceLookup | None = None,
3940
) -> EnvironmentService:
4041
storage = StudioStorageConfig.from_env(provider, environment)
4142
if not storage.configured:
4243
return EnvironmentService(
43-
None, None, unavailable_reason=storage.unavailable_reason
44+
None,
45+
None,
46+
workspace_references=workspace_references,
47+
unavailable_reason=storage.unavailable_reason,
4448
)
4549
if client_factory is None:
4650
if resolve_credentials is None:
4751
return EnvironmentService(
4852
None,
4953
None,
54+
workspace_references=workspace_references,
5055
unavailable_reason="管理员未配置环境存储与构建凭据。",
5156
)
5257
client_factory = create_tos_client_factory(storage, resolve_credentials)
@@ -67,6 +72,7 @@ def create_environment_service(
6772
settings,
6873
resolve_credentials=resolve_credentials,
6974
),
75+
workspace_references=workspace_references,
7076
)
7177

7278

frontend/server/environments/service.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import tarfile
2323
from datetime import datetime, timezone
2424
from pathlib import Path, PurePosixPath
25+
from typing import Protocol
2526
from uuid import uuid4
2627

2728
from veadk.cli.generated_agent_codegen import (
@@ -53,20 +54,32 @@
5354
EnvironmentView,
5455
ResolvedEnvironment,
5556
)
56-
from .repository import EnvironmentStorageUnavailable, TosEnvironmentRepository
57+
from .repository import (
58+
EnvironmentConflict,
59+
EnvironmentStorageUnavailable,
60+
TosEnvironmentRepository,
61+
)
5762
from .resources import EnvironmentCloudGateway
5863

5964

65+
class WorkspaceReferenceLookup(Protocol):
66+
async def workspace_names_for_environment(
67+
self, owner_id: str, environment_id: str
68+
) -> list[str]: ...
69+
70+
6071
class EnvironmentService:
6172
def __init__(
6273
self,
6374
repository: TosEnvironmentRepository | None,
6475
cloud: EnvironmentCloudGateway | None,
6576
*,
77+
workspace_references: WorkspaceReferenceLookup | None = None,
6678
unavailable_reason: str = "管理员未配置环境持久化存储。",
6779
) -> None:
6880
self._repository = repository
6981
self._cloud = cloud
82+
self._workspace_references = workspace_references
7083
self._unavailable_reason = unavailable_reason
7184
self._skillspace_resolver: SkillSpaceResolver | None = None
7285

@@ -151,6 +164,18 @@ async def update(
151164
return await self._view(repository, owner_id, saved)
152165

153166
async def delete(self, owner_id: str, environment_id: str) -> None:
167+
if self._workspace_references is not None:
168+
workspace_names = (
169+
await self._workspace_references.workspace_names_for_environment(
170+
owner_id, environment_id
171+
)
172+
)
173+
if workspace_names:
174+
names = "、".join(workspace_names[:3])
175+
suffix = "等工作区" if len(workspace_names) > 3 else "工作区"
176+
raise EnvironmentConflict(
177+
f"该环境正在被 {names}{suffix} 使用,请先从工作区中移除。"
178+
)
154179
await self._require_repository().delete(owner_id, environment_id)
155180

156181
async def start_build(self, owner_id: str, environment_id: str) -> EnvironmentBuild:
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Studio workspace backend composition."""
16+
17+
from __future__ import annotations
18+
19+
from collections.abc import Callable, Mapping
20+
from typing import Any
21+
22+
from frontend.server.environments.repository import TosEnvironmentRepository
23+
from frontend.server.storage import StudioProvider, StudioStorageConfig
24+
from frontend.server.storage.tos import CredentialResolver, create_tos_client_factory
25+
26+
from .repository import TosWorkspaceRepository
27+
from .routes import mount_workspace_routes
28+
from .service import WorkspaceService
29+
30+
31+
def create_workspace_service(
32+
*,
33+
provider: StudioProvider = "volcengine",
34+
resolve_credentials: CredentialResolver | None = None,
35+
client_factory: Callable[[], Any] | None = None,
36+
environment: Mapping[str, str] | None = None,
37+
) -> WorkspaceService:
38+
storage = StudioStorageConfig.from_env(provider, environment)
39+
if not storage.configured:
40+
return WorkspaceService(
41+
None, None, unavailable_reason=storage.unavailable_reason
42+
)
43+
if client_factory is None:
44+
if resolve_credentials is None:
45+
return WorkspaceService(
46+
None,
47+
None,
48+
unavailable_reason="管理员未配置工作区存储凭据。",
49+
)
50+
client_factory = create_tos_client_factory(storage, resolve_credentials)
51+
return WorkspaceService(
52+
TosWorkspaceRepository(bucket=storage.bucket, client_factory=client_factory),
53+
TosEnvironmentRepository(bucket=storage.bucket, client_factory=client_factory),
54+
)
55+
56+
57+
__all__ = ["WorkspaceService", "create_workspace_service", "mount_workspace_routes"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Validated contracts for Studio workspaces."""
16+
17+
from __future__ import annotations
18+
19+
from datetime import datetime
20+
21+
from pydantic import BaseModel, ConfigDict, Field, model_validator
22+
23+
24+
class WorkspaceInput(BaseModel):
25+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
26+
27+
name: str = Field(min_length=1, max_length=128)
28+
description: str = Field(default="", max_length=2000)
29+
environment_ids: list[str] = Field(
30+
default_factory=list, alias="environmentIds", max_length=100
31+
)
32+
33+
@model_validator(mode="after")
34+
def normalize(self) -> WorkspaceInput:
35+
self.name = self.name.strip()
36+
self.description = self.description.strip()
37+
if not self.name:
38+
raise ValueError("工作区名称不能为空。")
39+
self.environment_ids = list(
40+
dict.fromkeys(item.strip() for item in self.environment_ids if item.strip())
41+
)
42+
return self
43+
44+
45+
class WorkspacePatch(BaseModel):
46+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
47+
48+
name: str | None = Field(default=None, min_length=1, max_length=128)
49+
description: str | None = Field(default=None, max_length=2000)
50+
environment_ids: list[str] | None = Field(
51+
default=None, alias="environmentIds", max_length=100
52+
)
53+
54+
@model_validator(mode="after")
55+
def normalize(self) -> WorkspacePatch:
56+
if not self.model_fields_set:
57+
raise ValueError("至少需要更新一个工作区字段。")
58+
if self.name is not None:
59+
self.name = self.name.strip()
60+
if not self.name:
61+
raise ValueError("工作区名称不能为空。")
62+
if self.description is not None:
63+
self.description = self.description.strip()
64+
if self.environment_ids is not None:
65+
self.environment_ids = list(
66+
dict.fromkeys(
67+
item.strip() for item in self.environment_ids if item.strip()
68+
)
69+
)
70+
return self
71+
72+
73+
class WorkspaceRecord(WorkspaceInput):
74+
id: str = Field(min_length=32, max_length=32)
75+
owner_id: str = Field(alias="ownerId", min_length=1, max_length=1024)
76+
created_at: datetime = Field(alias="createdAt")
77+
updated_at: datetime = Field(alias="updatedAt")
78+
79+
80+
__all__ = ["WorkspaceInput", "WorkspacePatch", "WorkspaceRecord"]

0 commit comments

Comments
 (0)