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
7 changes: 4 additions & 3 deletions apps/control-plane-api/src/runtime/runtimeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "./sandboxConfig";
import { installSessionPackages } from "./sandboxPackageInstall";
import { ensureAliyunFcRuntime, ensureAliyunFcSandboxRuntime, invokeAliyunFc, aliyunFcLoopAgentEnv } from "./aliyunFcRuntime";
import { claimPooledAliyunFcSandboxRuntime, claimPooledDockerRuntime, claimPooledSandboxRuntime, replenishWorkspaceSandboxPool } from "./sandboxPoolManager";
import { claimPooledAliyunFcSandboxRuntime, claimPooledDockerRuntime, claimPooledSandboxRuntime, ensureAliyunFcSandboxProviderReady, replenishWorkspaceSandboxPool } from "./sandboxPoolManager";
import { ensureVefaasRuntime, invokeVefaas, vefaasLoopAgentConfig, vefaasLoopAgentEnv } from "./vefaasAgentRuntime";
import { ensureVefaasSandboxRuntime, killVefaasSandbox } from "./vefaasSandboxRuntime";

Expand Down Expand Up @@ -214,9 +214,10 @@ async function ensureSingleConfiguredSandboxRuntime(session: JsonRecord & { id:
}
if (config.provider === "aliyun_fc") {
const canClaimPool = process.env.MAPLE_SANDBOX_POOL_CLAIM !== "false";
const runtime = await ensureAliyunFcSandboxRuntime(session, config, { acquireRuntime: canClaimPool ? () => claimPooledAliyunFcSandboxRuntime(session, config) : undefined });
if (config.packages.length) console.warn("[runtime] Aliyun FC sandbox package installation is not implemented; packages are expected in the FC image.", { session_id: session.id });
const workspaceId = String(session.workspace_id || asRecord(session.metadata).workspace_id || "");
const activeConfig = workspaceId ? await ensureAliyunFcSandboxProviderReady(workspaceId, config) : config;
const runtime = await ensureAliyunFcSandboxRuntime(session, activeConfig, { acquireRuntime: canClaimPool ? () => claimPooledAliyunFcSandboxRuntime(session, activeConfig) : undefined });
if (config.packages.length) console.warn("[runtime] Aliyun FC sandbox package installation is not implemented; packages are expected in the FC image.", { session_id: session.id });
if (workspaceId && process.env.MAPLE_SANDBOX_POOL_AUTOREPLENISH !== "false") void replenishWorkspaceSandboxPool(workspaceId).catch(() => undefined);
return runtime;
}
Expand Down
102 changes: 100 additions & 2 deletions apps/control-plane-api/src/runtime/sandboxPoolManager.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
/* eslint-disable max-lines */
import { execFile } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { sessionsDir } from "../paths";
import {
countSandboxPoolStandbyCapacity,
createSandboxPoolMember,
db,
expireSandboxPoolMembers,
fromJson,
getWorkspace,
getWorkspaceSandboxPool,
hashString,
listWorkspaceSandboxPools,
markSandboxPoolMemberClaimed,
markSandboxPoolMemberFailed,
markSandboxPoolMemberReady,
now,
toJson,
updateSandboxPoolMemberRuntime
} from "../store";
import type { JsonRecord } from "../types";
Expand All @@ -30,6 +38,8 @@ type VefaasSandboxConfig = Extract<NormalizedSandboxRuntimeConfig, { provider: "
type LocalDockerSandboxConfig = Extract<NormalizedSandboxRuntimeConfig, { provider: "local_docker" }>;
type AliyunFcSandboxConfig = Extract<NormalizedSandboxRuntimeConfig, { provider: "aliyun_fc" }>;

const execFileAsync = promisify(execFile);

export async function replenishWorkspaceSandboxPool(workspaceId: string) {
const pools = listWorkspaceSandboxPools(workspaceId);
if (!pools.length) return { workspace_id: workspaceId, created: 0, reason: "workspace_not_found" };
Expand Down Expand Up @@ -71,14 +81,48 @@ async function replenishLocalDockerSandboxPool(workspaceId: string, desiredSize:
}

async function replenishAliyunFcSandboxPool(workspaceId: string, desiredSize: number, ttlMs: number) {
const config = workspaceSandboxRuntimeConfig(workspaceId, "aliyun_fc");
if (config.provider !== "aliyun_fc") return { workspace_id: workspaceId, provider: "aliyun_fc", created: 0, reason: "provider_config_missing" };
const rawConfig = workspaceSandboxRuntimeConfig(workspaceId, "aliyun_fc");
if (rawConfig.provider !== "aliyun_fc") return { workspace_id: workspaceId, provider: "aliyun_fc", created: 0, reason: "provider_config_missing" };
const config = await ensureAliyunFcSandboxProviderReady(workspaceId, rawConfig);
const current = countSandboxPoolStandbyCapacity(workspaceId, "aliyun_fc");
const missing = Math.max(0, desiredSize - current);
const created = await runLimited(Array.from({ length: missing }), 10, () => provisionAliyunFcStandby(workspaceId, config, ttlMs));
return { workspace_id: workspaceId, provider: "aliyun_fc", desired_size: desiredSize, created: created.filter(Boolean).length };
}

export async function ensureAliyunFcSandboxProviderReady(workspaceId: string, config: AliyunFcSandboxConfig): Promise<AliyunFcSandboxConfig> {
if (config.invoke_url) return config;
const deployScript = process.env.MAPLE_ALIYUN_FC_SANDBOX_DEPLOY_SCRIPT || process.env.MAPLE_ALIYUN_FC_RUNTIME_DEPLOY_SCRIPT || "infra/aliyun/deploy_aliyun_fc_runtime.mjs";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow managed Aliyun sandboxes past validation

With this default deploy script, an Aliyun FC sandbox no longer needs a pre-existing invoke_url, but the workspace creation path still rejects any Aliyun sandbox/pool that lacks ALIYUN_FC_INVOKE_URL in missingWorkspaceProvisioningCredentials (apps/control-plane-api/src/routes/workspaceRoutes.ts:286-292). In the normal API/UI onboarding flow with only Aliyun credentials, this new branch is never reached, so managed sandbox deployment cannot be used unless users manually edit the workspace config or pre-create a function URL.

Useful? React with 👍 / 👎.

const functionName = config.function_name || `maple-sbx-${workspaceId.replace(/[^a-zA-Z0-9-]/g, "").toLowerCase().slice(0, 24)}-${Date.now()}`;
const payload = await runAliyunFcDeployScript(deployScript, {
...process.env,
ALIYUN_ACCESS_KEY_ID: config.access_key_id,
ALIYUN_ACCESS_KEY_SECRET: config.access_key_secret,
ALIYUN_REGION: config.region || "cn-hangzhou",
MAPLE_ALIYUN_FC_COMPONENT: "agent-sandbox",
MAPLE_ALIYUN_FC_FUNCTION_NAME: functionName,
MAPLE_ALIYUN_FC_MEMORY_MB: String(Number(process.env.MAPLE_ALIYUN_FC_SANDBOX_MEMORY_MB || 1024)),
MAPLE_RUNTIME_FUNCTION_MAX_CONCURRENCY: String(Number(process.env.MAPLE_ALIYUN_FC_SANDBOX_CONCURRENCY || 20)),
MAPLE_ALIYUN_FC_RUNTIME_ENVS: JSON.stringify({
...config.envs,
MAPLE_WORKSPACE_ID: workspaceId,
MAPLE_AGENT_RUNTIME_ROLE: "sandbox",
MAPLE_AGENT_LOOP_RUNTIME: "managed-agents-platform-aliyun-fc-sandbox",
MAPLE_SKIP_CLAUDE_AGENT_SDK_INSTALL: "true"
})
});
const invokeUrl = String(payload.invoke_url || "").replace(/\/+$/, "");
if (!invokeUrl) throw new Error(`Aliyun FC sandbox deploy script returned incomplete payload: ${JSON.stringify(payload)}`);
const nextConfig = {
...config,
function_name: String(payload.function_name || payload.function_id || functionName),
invoke_url: invokeUrl,
region: String(payload.region || config.region || "cn-hangzhou")
};
updateWorkspaceAliyunFcSandboxConfig(workspaceId, nextConfig);
return nextConfig;
}

export async function claimPooledDockerRuntime(
session: JsonRecord & { id: string; workspace_path: string },
config: LocalDockerSandboxConfig
Expand Down Expand Up @@ -313,6 +357,60 @@ function vefaasRuntimeFromSandbox(
};
}

async function runAliyunFcDeployScript(script: string, env: NodeJS.ProcessEnv) {
const command = script.endsWith(".py") ? "python3" : (script.endsWith(".mjs") || script.endsWith(".js")) ? process.execPath : script;
const args = command === script ? [] : [script];
const { stdout } = await execFileAsync(command, args, {
cwd: process.cwd(),
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: Number(process.env.MAPLE_ALIYUN_FC_RELEASE_TIMEOUT_MS || 10 * 60 * 1000),
env
});
return JSON.parse(stdout) as JsonRecord;
}

function updateWorkspaceAliyunFcSandboxConfig(workspaceId: string, config: AliyunFcSandboxConfig) {
const row = db.prepare("SELECT config_json FROM workspaces WHERE id = ?").get(workspaceId) as JsonRecord | undefined;
if (!row) return;
const workspaceConfig = fromJson<JsonRecord>(String(row.config_json), {});
const sandboxConfig = asRecord(workspaceConfig.sandbox_config);
const currentAliyun = asRecord(sandboxConfig.aliyun_fc ?? sandboxConfig.aliyun);
const aliyunPatch = {
function_name: config.function_name,
invoke_url: config.invoke_url,
region: config.region,
workspace_path: config.workspace_path,
timeout_ms: config.timeout_ms
};
const sandboxPools = Array.isArray(workspaceConfig.sandbox_pools)
? workspaceConfig.sandbox_pools.map((pool) => {
const poolRecord = asRecord(pool);
if (String(poolRecord.provider) !== "aliyun_fc") return pool;
return {
...poolRecord,
config: {
...asRecord(poolRecord.config),
...aliyunPatch
}
};
})
: workspaceConfig.sandbox_pools;
const nextConfig = {
...workspaceConfig,
sandbox_config: {
...sandboxConfig,
aliyun_fc: {
...currentAliyun,
...aliyunPatch
}
},
sandbox_pools: sandboxPools
};
const configJson = toJson(nextConfig);
db.prepare("UPDATE workspaces SET config_json = ?, config_hash = ?, updated_at = ? WHERE id = ?").run(configJson, hashString(configJson), now(), workspaceId);
}

async function prepareStandby(runtime: VefaasSandboxRuntimeInfo) {
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ async function directAliyunFcRuntimeProvisioning(workspaceId: string, index: num
const functionName = `maple-ws-${workspaceId.replace(/[^a-zA-Z0-9-]/g, "").toLowerCase().slice(0, 20)}-${index + 1}-${Date.now()}`;
const creds = aliyunCredentials((providerCredentials?.aliyun ?? providerCredentials?.alibaba_cloud) as JsonRecord | undefined);
const region = creds.region || defaults.aliyun_fc.region || "cn-hangzhou";
const deployScript = process.env.MAPLE_ALIYUN_FC_RUNTIME_DEPLOY_SCRIPT || "";
const configuredInvokeUrl = String(process.env.MAPLE_ALIYUN_FC_INVOKE_URL || defaults.aliyun_fc.invoke_url || "");
const deployScript = process.env.MAPLE_ALIYUN_FC_RUNTIME_DEPLOY_SCRIPT || (configuredInvokeUrl ? "" : "infra/aliyun/deploy_aliyun_fc_runtime.mjs");
const configuredFunctionName = String(process.env.MAPLE_ALIYUN_FC_FUNCTION_NAME || defaults.aliyun_fc.function_name || functionName);
const envs = publicRuntimePoolMemberEnvs(runtimePoolMemberEnvs(defaults.aliyun_fc.envs, workspaceId, index, "managed-agents-platform-aliyun-fc"));
if (!deployScript) {
Expand Down Expand Up @@ -169,6 +169,10 @@ async function directAliyunFcRuntimeProvisioning(workspaceId: string, index: num
ALIYUN_REGION: region,
MAPLE_ALIYUN_FC_FUNCTION_NAME: functionName,
MAPLE_ALIYUN_FC_MEMORY_MB: String(poolConfig.memory_mb),
MAPLE_ALIYUN_FC_CPU_MILLI: String(poolConfig.cpu_milli),
MAPLE_RUNTIME_FUNCTION_MIN_INSTANCES: String(poolConfig.min_instances_per_function),
MAPLE_RUNTIME_FUNCTION_MAX_INSTANCES: String(poolConfig.max_instances_per_function),
MAPLE_RUNTIME_FUNCTION_MAX_CONCURRENCY: String(poolConfig.max_concurrency_per_instance),
MAPLE_ALIYUN_FC_RUNTIME_ENVS: JSON.stringify({
...runtimeEnvOverrides(process.env.MAPLE_ALIYUN_FC_RUNTIME_ENVS),
...runtimePoolMemberEnvs(defaults.aliyun_fc.envs, workspaceId, index, "managed-agents-platform-aliyun-fc"),
Expand Down
Loading
Loading