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
23 changes: 20 additions & 3 deletions frontend/src/create/CustomCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
prepareMcpAuth,
updateMcpAuthTokenInput,
} from "./mcpAuth";
import { resolveMcpGatewayEnv } from "./mcpGatewayEnv";
import {
normalizeDraft,
sanitizeGeneratedDraftCapabilities,
Expand Down Expand Up @@ -2606,24 +2607,40 @@ function collectDeploymentEnv(root: AgentDraft): RuntimeEnvConfiguration {
if (
selectedHarnessOptimizations(prepared.draft).includes("mcp_resilience")
) {
const gatewayEnv = resolveMcpGatewayEnv(
prepared.draft,
prepared.envValues,
);
const gatewayError = gatewayEnv.ok ? undefined : gatewayEnv.message;
selections.push({
env: [
{
key: "MCP_URLS",
required: true,
comment: "MCP 统一网关地址",
placeholder: "https://example.com/mcp",
comment: "由已添加的 MCP 工具注入",
placeholder: "由已添加的 HTTP MCP 工具自动生成",
help: "由已添加的 HTTP MCP 工具自动注入。",
readOnly: true,
requiredBy: [harnessSidecarOptionLabel("mcp_resilience")],
missingError: gatewayError,
},
{
key: "MCP_API_KEY",
required: true,
comment: "MCP 统一网关 API Key",
comment: "由已添加的 MCP 工具注入",
placeholder: "由已添加的 HTTP MCP 工具自动生成",
help: "由已添加的 HTTP MCP 工具自动注入。",
secret: true,
readOnly: true,
requiredBy: [harnessSidecarOptionLabel("mcp_resilience")],
missingError: gatewayError,
},
],
});
if (gatewayEnv.ok) {
fixedValues.MCP_URLS = gatewayEnv.urls.join(",");
fixedValues.MCP_API_KEY = gatewayEnv.apiKey;
}
}
const config = runtimeEnvConfiguration(selections);
return {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/create/deploymentEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface RuntimeEnvSpec {
hidden?: boolean;
/** User-facing optimization names that require this Runtime setting. */
requiredBy?: string[];
/** Actionable error shown when a derived required value cannot be produced. */
missingError?: string;
}

export interface RuntimeEnvSelection {
Expand Down Expand Up @@ -122,12 +124,16 @@ export function runtimeEnvRequirementHint(
spec: RuntimeEnvSpec,
): string | undefined {
const labels = runtimeEnvRequirementLabels(spec);
return labels.length
const requirement = labels.length
? `优化项「${labels.join("、")}」依赖此配置。`
: undefined;
: "";
return (
[requirement, spec.help?.trim()].filter(Boolean).join(" ") || undefined
);
}

export function runtimeEnvMissingError(spec: RuntimeEnvSpec): string {
if (spec.missingError?.trim()) return spec.missingError.trim();
const labels = runtimeEnvRequirementLabels(spec);
return labels.length
? `优化项「${labels.join("、")}」依赖此配置,请填写 ${spec.key}。`
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/create/mcpGatewayEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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.

import type { AgentDraft } from "./types";

export type McpGatewayEnvErrorReason =
| "missing_http_tool"
| "missing_url"
| "missing_api_key"
| "conflicting_api_keys";

export type McpGatewayEnvResolution =
| { ok: true; urls: string[]; apiKey: string }
| {
ok: false;
reason: McpGatewayEnvErrorReason;
message: string;
};

const ERROR_MESSAGES: Record<McpGatewayEnvErrorReason, string> = {
missing_http_tool:
"请返回“添加 MCP 工具”并添加至少一个 HTTP MCP 服务;MCP 稳定性治理不支持 stdio 服务。",
missing_url:
"已添加的 HTTP MCP 工具缺少有效服务地址,请返回“添加 MCP 工具”补充后再发布。",
missing_api_key:
"已添加的 HTTP MCP 工具缺少 Bearer Token,请返回“添加 MCP 工具”补充后再发布。",
conflicting_api_keys:
"多个 HTTP MCP 工具使用了不同凭证,而 MCP 稳定性治理当前只支持一个共享凭证;请统一凭证或改用统一网关。",
};

function failure(reason: McpGatewayEnvErrorReason): McpGatewayEnvResolution {
return { ok: false, reason, message: ERROR_MESSAGES[reason] };
}

function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

/** Resolve Sidecar gateway inputs from HTTP MCP tools configured earlier. */
export function resolveMcpGatewayEnv(
root: AgentDraft,
injectedEnvValues: Record<string, string>,
): McpGatewayEnvResolution {
const envValues: Record<string, string> = {};
const nodes: AgentDraft[] = [];
const visited = new Set<AgentDraft>();

const visit = (node: AgentDraft) => {
if (visited.has(node)) return;
visited.add(node);
nodes.push(node);
Object.assign(envValues, node.deployment?.envValues ?? {});
node.subAgents.forEach(visit);
node.workflow?.nodes.forEach((workflowNode) => visit(workflowNode.agent));
};
visit(root);
Object.assign(envValues, injectedEnvValues);

const httpTools = nodes.flatMap((node) =>
(node.mcpTools ?? []).filter((tool) => tool.transport === "http"),
);
if (httpTools.length === 0) return failure("missing_http_tool");

const urls: string[] = [];
const credentials = new Set<string>();
for (const tool of httpTools) {
const url = tool.url?.trim() ?? "";
if (!url || !isHttpUrl(url)) return failure("missing_url");

const envName = tool.authTokenEnv?.trim() ?? "";
const apiKey = envName ? (envValues[envName] ?? "").trim() : "";
if (!apiKey) return failure("missing_api_key");

urls.push(url);
credentials.add(apiKey);
}
if (credentials.size !== 1) return failure("conflicting_api_keys");

return { ok: true, urls, apiKey: [...credentials][0] };
}
68 changes: 50 additions & 18 deletions frontend/src/ui/ProjectPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ const DEPLOY_PHASE_ORDER: Record<string, number> = {
github: 8,
};

export const BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE =
"构建任务已经提交,但暂时无法确认最终状态。请稍后在 Code Pipeline 查看构建结果,避免重复部署。";

export function isBuildStatusConfirmationError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /RunPipeline result could not be reconciled|Polling build status failed/i.test(
message,
);
}

function advanceDeploymentPhase(
current: string | undefined,
next: string | undefined,
Expand Down Expand Up @@ -1964,7 +1974,12 @@ export function ProjectPreview({
});
return;
}
if (mountedRef.current) setDeployError(message);
const buildStatusUnconfirmed =
latestPhase === "build" && isBuildStatusConfirmationError(err);
const displayMessage = buildStatusUnconfirmed
? BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE
: message;
if (mountedRef.current) setDeployError(displayMessage);
if (mountedRef.current) setDeployResult(null);
const buildLog = finalizeBuildFailureLog();
operation.fail({
Expand All @@ -1983,17 +1998,21 @@ export function ProjectPreview({
startedAt: taskStartedAt,
status: "error",
phase: latestPhase,
label: "部署失败",
message: failedInBuild
? "构建镜像失败,详见构建日志。"
: failedInGithub
? "挂载 GitHub 持续交付失败,详见 GitHub 日志。"
: message,
label: buildStatusUnconfirmed ? "构建状态待确认" : "部署失败",
message: buildStatusUnconfirmed
? BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE
: failedInBuild
? "构建镜像失败,详见构建日志。"
: failedInGithub
? "挂载 GitHub 持续交付失败,详见 GitHub 日志。"
: message,
...(buildLog ? { buildLog } : terminalBuildLogUpdate("complete")),
...(failedInGithub
? { githubDelivery: true, githubLog: latestGithubLog }
: {}),
retry: requestDeploymentConfirmation,
...(buildStatusUnconfirmed
? {}
: { retry: requestDeploymentConfirmation }),
});
} finally {
if (mountedRef.current) setDeploying(false);
Expand Down Expand Up @@ -2893,7 +2912,10 @@ export function ProjectPreview({
className="pp-env-value pp-env-json-value"
value={row.value}
placeholder={
row.required ? "必填,尚未填写" : "可选,尚未填写"
row.placeholder ||
(row.required
? "必填,尚未填写"
: "可选,尚未填写")
}
readOnly={fixed}
disabled={
Expand Down Expand Up @@ -2949,9 +2971,10 @@ export function ProjectPreview({
}
value={displayedValue}
placeholder={
row.required
row.placeholder ||
(row.required
? "必填,尚未填写"
: "可选,尚未填写"
: "可选,尚未填写")
}
readOnly={fixed}
disabled={
Expand Down Expand Up @@ -3199,13 +3222,22 @@ export function ProjectPreview({
{deployError && (
<DeploymentErrorMessage
className="pp-error"
message={`${activePhase
? `${isRuntimeUpdate ? "更新" : "部署"}失败(${
deploymentSteps.find((step) => step.phase === activePhase)?.label ??
activePhase
}阶段):`
: ""}${deployError}`}
onRetry={requestDeploymentConfirmation}
message={
deployError === BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE
? `构建状态待确认:${deployError}`
: `${activePhase
? `${isRuntimeUpdate ? "更新" : "部署"}失败(${
deploymentSteps.find(
(step) => step.phase === activePhase,
)?.label ?? activePhase
}阶段):`
: ""}${deployError}`
}
onRetry={
deployError === BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE
? undefined
: requestDeploymentConfirmation
}
retryLabel={
isRuntimeUpdate ? "重试更新" : "重试部署"
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/agentWorkspace.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ test("workspace publish flow restores PR 748 deployment lifecycle hooks", () =>
assert.match(projectPreviewSource, /setActivePhase\(latestPhase\)/);
assert.match(
projectPreviewSource,
/label: "部署失败"[\s\S]*?message: failedInBuild[\s\S]*?\.\.\.\(buildLog/,
/label: buildStatusUnconfirmed[\s\S]*?"构建状态待确认"[\s\S]*?"部署失败"[\s\S]*?message: buildStatusUnconfirmed[\s\S]*?failedInBuild[\s\S]*?\.\.\.\(buildLog/,
);
assert.match(projectPreviewSource, /const failedInGithub = latestPhase === "github" && Boolean\(latestGithubLog\)/);
assert.match(projectPreviewSource, /failedInBuild[\s\S]*?"构建镜像失败,详见构建日志。"[\s\S]*?failedInGithub[\s\S]*?"挂载 GitHub 持续交付失败,详见 GitHub 日志。"/);
Expand Down
24 changes: 23 additions & 1 deletion frontend/tests/debugErrorPresentation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,34 @@ test("creation and deployment keep friendly context and the original error", ()
assert.match(clientSource, /原始响应:\\n\$\{text\}/);
assert.match(
projectPreviewSource,
/label: "部署失败"[\s\S]*?message: failedInBuild[\s\S]*?\.\.\.\(buildLog/,
/label: buildStatusUnconfirmed[\s\S]*?"构建状态待确认"[\s\S]*?"部署失败"[\s\S]*?message: buildStatusUnconfirmed[\s\S]*?failedInBuild[\s\S]*?\.\.\.\(buildLog/,
);
assert.match(
projectPreviewSource,
/failedInBuild[\s\S]*?"构建镜像失败,详见构建日志。"[\s\S]*?failedInGithub[\s\S]*?"挂载 GitHub 持续交付失败,详见 GitHub 日志。"[\s\S]*?: message/,
);
assert.match(
projectPreviewSource,
/isBuildStatusConfirmationError[\s\S]*?RunPipeline result could not be reconciled[\s\S]*?Polling build status failed/,
);
assert.doesNotMatch(
projectPreviewSource.match(
/export function isBuildStatusConfirmationError[\s\S]*?\n}/,
)?.[0] ?? "",
/network error|fetch failed|Volcengine request timed out/i,
);
assert.match(
projectPreviewSource,
/buildStatusUnconfirmed[\s\S]*?BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE[\s\S]*?failedInBuild/,
);
assert.match(
projectPreviewSource,
/deployError === BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE[\s\S]*?undefined[\s\S]*?: requestDeploymentConfirmation/,
);
assert.match(
projectPreviewSource,
/deployError === BUILD_STATUS_CONFIRMATION_ERROR_MESSAGE[\s\S]*?`构建状态待确认:\$\{deployError\}`/,
);
});

test("generated-agent debug requests preserve backend error details", () => {
Expand Down
31 changes: 31 additions & 0 deletions frontend/tests/deploymentEnv.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,23 @@ test("explains optimization dependencies and reports every missing runtime setti
}),
[],
);

const derivedSpec = {
key: "MCP_URLS",
required: true,
readOnly: true,
requiredBy: ["MCP 稳定性治理"],
help: "由已添加的 HTTP MCP 工具自动注入。",
missingError: "请返回“添加 MCP 工具”补充配置。",
};
assert.equal(
runtimeEnvRequirementHint(derivedSpec),
"优化项「MCP 稳定性治理」依赖此配置。 由已添加的 HTTP MCP 工具自动注入。",
);
assert.equal(
runtimeEnvMissingError(derivedSpec),
"请返回“添加 MCP 工具”补充配置。",
);
});

test("marks missing optimization env inputs invalid and focuses the first error", () => {
Expand All @@ -363,7 +380,21 @@ test("marks missing optimization env inputs invalid and focuses the first error"
customCreateSource,
/requiredBy:\s*\[harnessSidecarOptionLabel\("mcp_resilience"\)\]/,
);
assert.match(customCreateSource, /resolveMcpGatewayEnv\(/);
assert.match(
customCreateSource,
/fixedValues\.MCP_URLS = gatewayEnv\.urls\.join\(","\)/,
);
assert.match(
customCreateSource,
/fixedValues\.MCP_API_KEY = gatewayEnv\.apiKey/,
);
assert.match(
customCreateSource,
/key: "MCP_API_KEY",[\s\S]*?secret: true,[\s\S]*?readOnly: true/,
);
assert.match(projectPreviewSource, /missingRuntimeEnvs\(/);
assert.match(projectPreviewSource, /row\.placeholder \|\|/);
assert.match(projectPreviewSource, /setDeploymentEnvErrors\(/);
assert.match(projectPreviewSource, /focusDeploymentEnv\(/);
assert.match(
Expand Down
Loading
Loading