Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions frontend/server/studio_update_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Provision account-local resources introduced after a Studio deployment."""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor
from typing import Any, Literal

from frontend.server.storage.provisioning import resolve_studio_storage_for_deploy
from veadk.utils.cloud_provider import CloudProvider

SnapshotKind = Literal["codex", "openclaw", "hermes"]

_SNAPSHOT_ENVIRONMENTS: tuple[tuple[str, SnapshotKind, str], ...] = (
("SANDBOX_CHAT_CODEX_SNAPSHOT", "codex", "chat"),
("SANDBOX_CHAT_OPENCLAW_SNAPSHOT", "openclaw", "openclaw"),
("SANDBOX_CHAT_HERMES_SNAPSHOT", "hermes", "hermes"),
)


def _function_environment(function_client: Any, function_id: str) -> dict[str, str]:
import volcenginesdkvefaas

function = function_client.get_function(
volcenginesdkvefaas.GetFunctionRequest(id=function_id)
)
return {
str(item.key): str(item.value)
for item in (getattr(function, "envs", None) or [])
if getattr(item, "key", None)
}


def _provision_snapshot_tool(
*,
kind: SnapshotKind,
purpose: str,
provider: CloudProvider,
region: str,
application_id: str,
access_key: str,
secret_key: str,
session_token: str,
) -> str:
from veadk.cli.frontend_skill_creator import (
ensure_skill_creator_model_credential,
)
from veadk.cli.studio_sandbox_tools import (
ensure_studio_agent_model_credential,
ensure_studio_agent_tool,
ensure_studio_code_env_tool,
studio_sandbox_agent_model_name,
studio_sandbox_model_base_url,
studio_sandbox_tool_name,
)

tool_name = studio_sandbox_tool_name(
application_id,
purpose,
snapshot=True,
)
model_name = studio_sandbox_agent_model_name(provider)
if kind == "codex":
tool_id = ensure_studio_code_env_tool(
name=tool_name,
enable_snapshot=True,
region=region,
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
)
ensure_skill_creator_model_credential(
tool_id=tool_id,
region=region,
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
provider=provider,
model_name=model_name,
)
return tool_id

tool_id = ensure_studio_agent_tool(
name=tool_name,
kind=kind,
enable_snapshot=True,
model_name=model_name,
region=region,
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
)
ensure_studio_agent_model_credential(
tool_id=tool_id,
kind=kind,
model_name=model_name,
model_base_url=studio_sandbox_model_base_url(provider),
region=region,
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
provider=provider,
)
return tool_id


def reconcile_studio_update_resources(
*,
provider: CloudProvider,
region: str,
application_id: str,
function_id: str,
function_client: Any,
access_key: str,
secret_key: str,
session_token: str,
) -> dict[str, str]:
"""Return environment overrides for resources missing from an older Studio."""
environment = _function_environment(function_client, function_id)
overrides: dict[str, str] = {}

if not (
environment.get("VEADK_STUDIO_TOS_BUCKET")
and environment.get("VEADK_STUDIO_TOS_REGION")
):
storage = resolve_studio_storage_for_deploy(
provider=provider,
region=region,
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
source=environment,
)
overrides.update(
{
"VEADK_STUDIO_TOS_BUCKET": storage.bucket,
"VEADK_STUDIO_TOS_REGION": storage.region,
}
)

missing_snapshot_tools = [
item for item in _SNAPSHOT_ENVIRONMENTS if not environment.get(item[0])
]
if missing_snapshot_tools:
with ThreadPoolExecutor(max_workers=len(missing_snapshot_tools)) as executor:
futures = {
environment_key: executor.submit(
_provision_snapshot_tool,
kind=kind,
purpose=purpose,
provider=provider,
region=region,
application_id=application_id,
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
)
for environment_key, kind, purpose in missing_snapshot_tools
}
for environment_key, _kind, _purpose in missing_snapshot_tools:
overrides[environment_key] = futures[environment_key].result()

return overrides


__all__ = ["reconcile_studio_update_resources"]
4 changes: 3 additions & 1 deletion frontend/service/studio_release_server/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ def _studio_run_script() -> str:
"HOST=0.0.0.0\n"
"PORT=${_FAAS_RUNTIME_PORT:-8000}\n"
"export PYTHONPATH=$PYTHONPATH:./site-packages\n"
"exec python3 -m veadk.cli.cli studio --auth-mode frontend "
"exec python3 -m veadk.cli.cli studio "
'--provider "${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}" '
"--auth-mode frontend "
'--host "$HOST" --port "$PORT"\n'
)

Expand Down
21 changes: 18 additions & 3 deletions frontend/src/ui/StudioUpdateControl.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
overflow: hidden;
}

.studio-update-dialog.is-progress {
width: min(760px, calc(100vw - 32px));
}

.studio-update-dialog > .studio-update-dialog-mark {
grid-column: 1;
grid-row: 1;
Expand Down Expand Up @@ -326,17 +330,28 @@
}

.studio-update-changelog ul {
display: grid;
gap: 5px;
display: block;
max-height: min(180px, 25vh);
margin: 0;
padding: 0 6px 0 18px;
overflow-y: auto;
list-style: disc outside;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}

.studio-update-changelog li,
.studio-update-changelog li {
display: list-item;
margin: 0;
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 1.55;
}

.studio-update-changelog li + li {
margin-top: 5px;
}

.studio-update-changelog p {
margin: 0;
color: hsl(var(--muted-foreground));
Expand Down
38 changes: 26 additions & 12 deletions frontend/src/ui/StudioUpdateControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const UPDATE_STEPS = [
{ id: "resolving", label: "读取目标版本信息" },
{ id: "downloading", label: "下载并校验完整更新包" },
{ id: "preparing", label: "准备 VeFaaS Function 代码" },
{ id: "provisioning", label: "检查并补齐 Studio 云资源" },
{ id: "submitting", label: "提交 Function 更新" },
{ id: "publishing", label: "发布新 Revision 并重启服务" },
] as const;
Expand All @@ -29,6 +30,7 @@ const UPDATE_STAGE_LABELS: Record<string, string> = {
resolving: "读取版本信息",
downloading: "下载更新包",
preparing: "准备 Function 代码",
provisioning: "补齐 Studio 云资源",
submitting: "提交 Function 更新",
publishing: "发布 Revision",
checking: "检查更新",
Expand Down Expand Up @@ -117,25 +119,34 @@ function StudioUpdateLog({
lines: string[];
phase: "active" | "complete" | "error";
copyState: LogCopyState;
onCopy: () => void;
onCopy: (lines: string[]) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
const [visibleLines, setVisibleLines] = useState(lines);

useEffect(() => {
if (lines.length) setVisibleLines(lines);
}, [lines]);

useEffect(() => {
const root = scrollRef.current;
if (root && followRef.current) root.scrollTop = root.scrollHeight;
}, [lines]);
}, [visibleLines]);

return (
<section className="studio-update-live-log" aria-label="VeFaaS 更新日志">
<section className="studio-update-live-log" aria-label="VeFaaS 实时部署日志">
<div className="studio-update-log-header">
<span>
<i className={`is-${phase}`} aria-hidden />
VeFaaS 更新日志
VeFaaS 实时部署日志
<small>{phase === "active" ? "实时" : phase === "complete" ? "已完成" : "已停止"}</small>
</span>
<button type="button" onClick={onCopy} disabled={!lines.length}>
<button
type="button"
onClick={() => onCopy(visibleLines)}
disabled={!visibleLines.length}
>
{copyState === "copied"
? "已复制"
: copyState === "error"
Expand All @@ -148,15 +159,16 @@ function StudioUpdateLog({
className="studio-update-log-lines"
role="log"
aria-live="off"
aria-busy={phase === "active"}
tabIndex={0}
onScroll={(event) => {
const root = event.currentTarget;
followRef.current =
root.scrollHeight - root.scrollTop - root.clientHeight < 24;
}}
>
{lines.length ? (
lines.map((line, index) => <div key={`${index}-${line}`}>{line}</div>)
{visibleLines.length ? (
visibleLines.map((line, index) => <div key={`${index}-${line}`}>{line}</div>)
) : (
<p>{phase === "active" ? "等待 VeFaaS 返回更新日志…" : "本次更新未返回发布日志"}</p>
)}
Expand Down Expand Up @@ -336,9 +348,9 @@ export function StudioUpdateControl({
.split("\n")
.filter(Boolean);

const copyUpdateLog = async () => {
const copyUpdateLog = async (lines: string[]) => {
try {
await navigator.clipboard.writeText(updateLogs.join("\n"));
await navigator.clipboard.writeText(lines.join("\n"));
setLogCopyState("copied");
} catch {
setLogCopyState("error");
Expand Down Expand Up @@ -401,7 +413,9 @@ export function StudioUpdateControl({
createPortal(
<div className="confirm-scrim" role="presentation">
<section
className="confirm-box studio-update-dialog"
className={`confirm-box studio-update-dialog${
phase === "confirm" ? "" : " is-progress"
}`}
role="dialog"
aria-modal="true"
aria-labelledby="studio-update-title"
Expand Down Expand Up @@ -439,7 +453,7 @@ export function StudioUpdateControl({
lines={updateLogs}
phase="error"
copyState={logCopyState}
onCopy={() => void copyUpdateLog()}
onCopy={(lines) => void copyUpdateLog(lines)}
/>
{status.consoleUrl && (
<a
Expand Down Expand Up @@ -497,7 +511,7 @@ export function StudioUpdateControl({
lines={updateLogs}
phase={phase === "published" ? "complete" : "active"}
copyState={logCopyState}
onCopy={() => void copyUpdateLog()}
onCopy={(lines) => void copyUpdateLog(lines)}
/>
<p className="studio-update-progress-note">
发布阶段会短暂中断连接;关闭此窗口不会停止更新,可随时点击右上角按钮重新查看。
Expand Down
13 changes: 12 additions & 1 deletion frontend/tests/studioUpdate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,21 @@ test("Studio explains the update restart window", () => {
assert.match(controlSource, /<span>选择版本<\/span>/);
assert.match(controlSource, /targetRelease\.changelog\.map/);
assert.match(controlSource, /暂无更新说明/);
assert.match(
controlStyleSource,
/\.studio-update-changelog ul\s*\{[\s\S]*?list-style:\s*disc outside;/,
);
assert.match(
controlStyleSource,
/\.studio-update-changelog li\s*\{[\s\S]*?display:\s*list-item;/,
);
assert.match(controlStyleSource, /background: #1664ff/);
});

test("Studio exposes detailed update stages that can be reopened", () => {
assert.match(controlSource, /下载并校验完整更新包/);
assert.match(controlSource, /准备 VeFaaS Function 代码/);
assert.match(controlSource, /检查并补齐 Studio 云资源/);
assert.match(controlSource, /发布新 Revision 并重启服务/);
assert.match(controlSource, /setDialogOpen\(true\)/);
assert.match(controlSource, /关闭此窗口不会停止更新/);
Expand All @@ -120,9 +129,11 @@ test("Studio exposes detailed update stages that can be reopened", () => {

test("Studio renders bounded VeFaaS logs without stealing manual scroll", () => {
assert.match(clientSource, /updateLogs: string\[\]/);
assert.match(controlSource, /VeFaaS 更新日志/);
assert.match(controlSource, /VeFaaS 实时部署日志/);
assert.match(controlSource, /role="log"/);
assert.match(controlSource, /aria-live="off"/);
assert.match(controlSource, /aria-busy=\{phase === "active"\}/);
assert.match(controlSource, /if \(lines\.length\) setVisibleLines\(lines\)/);
assert.match(
controlSource,
/root\.scrollHeight - root\.scrollTop - root\.clientHeight < 24/,
Expand Down
Loading
Loading