Files
Genarrative/server-rs/crates/api-server/src/web_project.rs
Linghong 4b09ce3096 完成 Editor Agent Mock Agent P1 收尾
接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面
新增 Mock Agent、静态构建 runner 与独立预览网关
补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅
修复 sandbox 预览资源跨域加载并补充并发保护
接入本地 dev 预览端口漂移与服务身份初始化
更新 P1 技术方案、验收清单和 Hermes 共享记忆
2026-06-16 17:31:25 +08:00

1092 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
convert::Infallible,
path::PathBuf,
process::Stdio,
time::{SystemTime, UNIX_EPOCH},
};
use async_stream::stream;
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::web_project::{
MockAgentTurnRequest, MockAgentTurnResponse, WebProjectFile, WebProjectPatch,
WebProjectPatchOperation, WebProjectPreviewBuild, WebProjectPreviewBuildEvent,
WebProjectPreviewBuildResponse, WebProjectPreviewBuildStatus, WebProjectResponse,
WebProjectSnapshot, WebProjectSnapshotResponse,
};
use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string};
use spacetime_client::{
SpacetimeClientError, WebProjectCreateRecordInput, WebProjectFileRecord,
WebProjectGetRecordInput, WebProjectPreviewBuildCreateRecordInput,
WebProjectPreviewBuildGetRecordInput, WebProjectPreviewBuildRecord, WebProjectRecord,
WebProjectSnapshotGetRecordInput, WebProjectSnapshotRecord, WebProjectSnapshotSaveRecordInput,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
process::Command,
time::Duration,
};
use web_project_runner::{
WebProjectBuildInput, WebProjectBuildOutput, WebProjectBuildStatus, WebProjectRunnerFile,
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::{AppState, WebProjectPreviewBuildSlot, WebProjectPreviewBuildSlotError},
web_project_mock_agent::build_mock_agent_patch,
};
const WEB_PROJECT_ID_PREFIX: &str = "web-project-";
const WEB_PROJECT_SNAPSHOT_ID_PREFIX: &str = "web-snapshot-";
const WEB_PROJECT_BUILD_ID_PREFIX: &str = "web-build-";
const WEB_PROJECT_DEFAULT_TITLE: &str = "未命名 Web 工程";
const WEB_PROJECT_MAX_PATH_DEPTH: usize = 8;
const WEB_PROJECT_MAX_FILE_COUNT: usize = 80;
const WEB_PROJECT_MAX_FILE_BYTES: usize = 128 * 1024;
const WEB_PROJECT_MAX_SNAPSHOT_BYTES: usize = 512 * 1024;
const WEB_PROJECT_RUNNER_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebProjectCreateRequest {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebProjectFilesPatchRequest {
patch: WebProjectPatch,
base_snapshot_id: String,
summary: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebProjectPreviewBuildCreateRequest {
snapshot_id: Option<String>,
}
pub async fn create_web_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<WebProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
let project_id = build_prefixed_uuid_id(WEB_PROJECT_ID_PREFIX);
let snapshot_id = build_prefixed_uuid_id(WEB_PROJECT_SNAPSHOT_ID_PREFIX);
let owner_user_id = authenticated.claims().user_id().to_string();
let title = payload
.title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| WEB_PROJECT_DEFAULT_TITLE.to_string());
let initial_files = initial_template_files();
validate_snapshot_files(&initial_files)?;
let mutation = state
.spacetime_client()
.create_web_project(WebProjectCreateRecordInput {
project_id,
snapshot_id,
owner_user_id,
title,
initial_files: initial_files
.into_iter()
.map(web_project_file_to_record)
.collect(),
now_micros: current_utc_micros(),
})
.await
.map_err(map_web_project_client_error)?;
Ok(json_success_body(
Some(&request_context),
WebProjectResponse {
project: project_from_record(mutation.project),
},
))
}
pub async fn get_web_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let project = state
.spacetime_client()
.get_web_project(WebProjectGetRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_web_project_client_error)?;
Ok(json_success_body(
Some(&request_context),
WebProjectResponse {
project: project_from_record(project),
},
))
}
pub async fn get_web_project_snapshot(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let mutation = state
.spacetime_client()
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
project_id,
snapshot_id: None,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_web_project_client_error)?;
Ok(json_success_body(
Some(&request_context),
WebProjectSnapshotResponse {
snapshot: snapshot_from_record(mutation.snapshot),
},
))
}
pub async fn patch_web_project_files(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<WebProjectFilesPatchRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let base_snapshot = state
.spacetime_client()
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
project_id: project_id.clone(),
snapshot_id: Some(payload.base_snapshot_id.clone()),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(map_web_project_client_error)?
.snapshot;
let base_snapshot_payload = snapshot_from_record(base_snapshot.clone());
let files = apply_web_project_patch(&base_snapshot_payload.files, &payload.patch)?;
let mutation = state
.spacetime_client()
.save_web_project_snapshot(WebProjectSnapshotSaveRecordInput {
snapshot_id: build_prefixed_uuid_id(WEB_PROJECT_SNAPSHOT_ID_PREFIX),
project_id,
owner_user_id,
parent_snapshot_id: Some(base_snapshot.snapshot_id),
files: files.into_iter().map(web_project_file_to_record).collect(),
patch_summary: payload
.summary
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "手动更新 Web 工程文件".to_string()),
created_by: "user".to_string(),
now_micros: current_utc_micros(),
})
.await
.map_err(map_web_project_client_error)?;
Ok(json_success_body(
Some(&request_context),
WebProjectSnapshotResponse {
snapshot: snapshot_from_record(mutation.snapshot),
},
))
}
pub async fn create_mock_agent_turn(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<MockAgentTurnRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let base_snapshot = state
.spacetime_client()
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
project_id: project_id.clone(),
snapshot_id: Some(payload.base_snapshot_id.clone()),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(map_web_project_client_error)?
.snapshot;
let (patch, summary) = build_mock_agent_patch(&payload.prompt);
let base_snapshot_payload = snapshot_from_record(base_snapshot.clone());
let files = apply_web_project_patch(&base_snapshot_payload.files, &patch)?;
let mutation = state
.spacetime_client()
.save_web_project_snapshot(WebProjectSnapshotSaveRecordInput {
snapshot_id: build_prefixed_uuid_id(WEB_PROJECT_SNAPSHOT_ID_PREFIX),
project_id,
owner_user_id,
parent_snapshot_id: Some(base_snapshot.snapshot_id),
files: files.into_iter().map(web_project_file_to_record).collect(),
patch_summary: summary.clone(),
created_by: "mock-agent".to_string(),
now_micros: current_utc_micros(),
})
.await
.map_err(map_web_project_client_error)?;
Ok(json_success_body(
Some(&request_context),
MockAgentTurnResponse {
snapshot: snapshot_from_record(mutation.snapshot),
patch,
summary,
},
))
}
pub async fn create_web_project_preview_build(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<WebProjectPreviewBuildCreateRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let snapshot_id = match payload.snapshot_id {
Some(snapshot_id) if !snapshot_id.trim().is_empty() => snapshot_id,
_ => {
state
.spacetime_client()
.get_web_project(WebProjectGetRecordInput {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(map_web_project_client_error)?
.active_snapshot_id
}
};
let build_slot = state
.try_acquire_web_project_preview_build_slot(&project_id)
.map_err(|error| map_preview_build_slot_error(error, project_id.as_str()))?;
let mutation = state
.spacetime_client()
.create_web_project_preview_build(WebProjectPreviewBuildCreateRecordInput {
job_id: build_prefixed_uuid_id(WEB_PROJECT_BUILD_ID_PREFIX),
project_id,
snapshot_id,
owner_user_id,
now_micros: current_utc_micros(),
})
.await
.map_err(map_web_project_client_error)?;
let mut build = build_from_record(mutation.build);
build.logs.push("构建任务已进入队列".to_string());
publish_build_event(&state, &build, Some("构建任务已进入队列"));
spawn_preview_build_task(
state.clone(),
authenticated.claims().user_id().to_string(),
build.clone(),
build_slot,
);
Ok(json_success_body(
Some(&request_context),
WebProjectPreviewBuildResponse { build },
))
}
pub async fn get_web_project_preview_build(
State(state): State<AppState>,
Path(job_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let mutation = state
.spacetime_client()
.get_web_project_preview_build(WebProjectPreviewBuildGetRecordInput {
job_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_web_project_client_error)?;
Ok(json_success_body(
Some(&request_context),
WebProjectPreviewBuildResponse {
build: build_from_record(mutation.build),
},
))
}
pub async fn stream_web_project_preview_build_events(
State(state): State<AppState>,
Path(job_id): Path<String>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Response, AppError> {
let initial_build = state
.spacetime_client()
.get_web_project_preview_build(WebProjectPreviewBuildGetRecordInput {
job_id: job_id.clone(),
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_web_project_client_error)?
.build;
let initial_build = build_from_record(initial_build);
let initial_is_terminal = matches!(
initial_build.status,
WebProjectPreviewBuildStatus::Succeeded
| WebProjectPreviewBuildStatus::Failed
| WebProjectPreviewBuildStatus::Cancelled
| WebProjectPreviewBuildStatus::Expired
| WebProjectPreviewBuildStatus::Stale
);
let initial_payload = serde_json::to_string(&WebProjectPreviewBuildEvent {
job_id: initial_build.job_id.clone(),
status: initial_build.status.clone(),
message: None,
build: Some(initial_build),
})
.ok();
let mut receiver = state.web_project_build_updates().subscribe();
let stream = stream! {
if let Some(payload) = initial_payload {
yield Ok::<Event, Infallible>(Event::default().event("message").data(payload));
}
if initial_is_terminal {
return;
}
while let Ok(payload) = receiver.recv().await {
let matches = serde_json::from_str::<WebProjectPreviewBuildEvent>(&payload)
.ok()
.is_some_and(|event| event.job_id == job_id);
if matches {
yield Ok::<Event, Infallible>(Event::default().event("message").data(payload));
}
}
};
Ok(Sse::new(stream).into_response())
}
pub fn apply_web_project_patch(
base_files: &[WebProjectFile],
patch: &WebProjectPatch,
) -> Result<Vec<WebProjectFile>, AppError> {
validate_web_project_patch(patch)?;
let mut files = base_files
.iter()
.map(|file| (file.path.clone(), file.clone()))
.collect::<BTreeMap<_, _>>();
for operation in &patch.operations {
match operation {
WebProjectPatchOperation::CreateFile { path, content } => {
if files.contains_key(path) {
return Err(web_project_bad_request("文件已存在", Some(path)));
}
files.insert(path.clone(), build_text_file(path, content)?);
}
WebProjectPatchOperation::UpdateFile { path, content } => {
if !files.contains_key(path) {
return Err(web_project_bad_request("文件不存在", Some(path)));
}
files.insert(path.clone(), build_text_file(path, content)?);
}
WebProjectPatchOperation::DeleteFile { path } => {
validate_editable_project_path(path)?;
files.remove(path);
}
WebProjectPatchOperation::RenameFile { from_path, to_path } => {
validate_editable_project_path(from_path)?;
validate_editable_project_path(to_path)?;
if from_path != to_path && files.contains_key(to_path) {
return Err(web_project_bad_request("目标文件已存在", Some(to_path)));
}
let mut file = files
.remove(from_path)
.ok_or_else(|| web_project_bad_request("源文件不存在", Some(from_path)))?;
file.path = to_path.clone();
files.insert(to_path.clone(), file);
}
WebProjectPatchOperation::PackageManifestRequest { .. } => {}
}
}
let next = files.into_values().collect::<Vec<_>>();
validate_snapshot_files(&next)?;
Ok(next)
}
pub fn validate_web_project_patch(patch: &WebProjectPatch) -> Result<(), AppError> {
if patch.operations.is_empty() {
return Err(web_project_bad_request("patch 不能为空", None));
}
for operation in &patch.operations {
match operation {
WebProjectPatchOperation::CreateFile { path, content }
| WebProjectPatchOperation::UpdateFile { path, content } => {
validate_editable_project_path(path)?;
validate_text_content(path, content)?;
}
WebProjectPatchOperation::DeleteFile { path } => {
validate_editable_project_path(path)?;
}
WebProjectPatchOperation::RenameFile { from_path, to_path } => {
validate_editable_project_path(from_path)?;
validate_editable_project_path(to_path)?;
}
WebProjectPatchOperation::PackageManifestRequest { content } => {
validate_package_manifest_request(content)?;
}
}
}
Ok(())
}
fn initial_template_files() -> Vec<WebProjectFile> {
vec![
build_text_file(
"src/App.tsx",
r#"import './App.css';
export default function App() {
return (
<main className="app-shell">
<section className="hero-panel">
<p className="eyebrow">Mock Agent</p>
<h1>Web 工程预览</h1>
</section>
</main>
);
}
"#,
)
.expect("template app should be valid"),
build_text_file(
"src/App.css",
r#"body { margin: 0; font-family: system-ui, sans-serif; }
.app-shell { min-height: 100vh; display: grid; place-items: center; }
.hero-panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px; }
.eyebrow { color: #2563eb; font-weight: 700; }
"#,
)
.expect("template css should be valid"),
]
}
fn build_text_file(path: &str, content: &str) -> Result<WebProjectFile, AppError> {
validate_editable_project_path(path)?;
validate_text_content(path, content)?;
Ok(WebProjectFile {
path: path.to_string(),
content: content.to_string(),
media_type: media_type_for_path(path).to_string(),
encoding: "utf-8".to_string(),
size_bytes: u32::try_from(content.as_bytes().len()).unwrap_or(u32::MAX),
})
}
fn validate_snapshot_files(files: &[WebProjectFile]) -> Result<(), AppError> {
if files.is_empty() {
return Err(web_project_bad_request("snapshot 不能为空", None));
}
if files.len() > WEB_PROJECT_MAX_FILE_COUNT {
return Err(web_project_bad_request("snapshot 文件数量过多", None));
}
let mut total = 0usize;
for file in files {
validate_editable_project_path(&file.path)?;
validate_text_content(&file.path, &file.content)?;
total = total.saturating_add(file.content.as_bytes().len());
}
if total > WEB_PROJECT_MAX_SNAPSHOT_BYTES {
return Err(web_project_bad_request("snapshot 总大小过大", None));
}
Ok(())
}
fn validate_project_path(path: &str) -> Result<(), AppError> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(web_project_bad_request("文件路径不能为空", None));
}
if path != trimmed {
return Err(web_project_bad_request(
"文件路径不能包含首尾空白",
Some(path),
));
}
if trimmed.starts_with('/') || trimmed.starts_with('\\') || trimmed.contains(':') {
return Err(web_project_bad_request(
"文件路径必须是相对路径",
Some(trimmed),
));
}
let parts = trimmed
.split(['/', '\\'])
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.len() > WEB_PROJECT_MAX_PATH_DEPTH {
return Err(web_project_bad_request("文件路径层级过深", Some(trimmed)));
}
for part in parts {
if part == "." || part == ".." {
return Err(web_project_bad_request(
"文件路径不能包含相对跳转",
Some(trimmed),
));
}
let lower = part.to_ascii_lowercase();
if matches!(
lower.as_str(),
".env" | ".npmrc" | ".git" | ".ssh" | "package-lock.json" | "npm-shrinkwrap.json"
) {
return Err(web_project_bad_request(
"文件路径包含敏感文件名",
Some(trimmed),
));
}
}
Ok(())
}
fn validate_editable_project_path(path: &str) -> Result<(), AppError> {
validate_project_path(path)?;
let normalized = path.replace('\\', "/");
let lower = normalized.to_ascii_lowercase();
if lower == "package.json" {
return Ok(());
}
if matches!(
lower.as_str(),
"index.html" | "src/main.tsx" | "tsconfig.json" | "vite.config.ts"
) {
return Err(web_project_bad_request(
"P1 不允许覆盖固定模板控制文件",
Some(path),
));
}
if matches!(lower.as_str(), "src/app.tsx" | "src/app.css")
|| ((lower.starts_with("src/components/")
|| lower.starts_with("src/assets/")
|| lower.starts_with("public/"))
&& has_editable_project_extension(lower.as_str()))
{
return Ok(());
}
Err(web_project_bad_request(
"P1 仅允许编辑 src/App.tsx、src/App.css、src/components、src/assets 与 public 下的静态文件",
Some(path),
))
}
fn validate_text_content(path: &str, content: &str) -> Result<(), AppError> {
if content.as_bytes().len() > WEB_PROJECT_MAX_FILE_BYTES {
return Err(web_project_bad_request("文件内容过大", Some(path)));
}
if content.contains('\0') {
return Err(web_project_bad_request(
"文件内容不能包含二进制空字节",
Some(path),
));
}
if path == "package.json" {
validate_package_manifest_request(content)?;
}
Ok(())
}
fn has_editable_project_extension(path: &str) -> bool {
matches!(
std::path::Path::new(path)
.extension()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or(""),
"ts" | "tsx" | "js" | "jsx" | "css" | "json" | "svg" | "txt" | "md"
)
}
fn validate_package_manifest_request(content: &str) -> Result<(), AppError> {
let value = serde_json::from_str::<serde_json::Value>(content)
.map_err(|_| web_project_bad_request("package manifest 不是合法 JSON", None))?;
if value.get("dependencies").is_some()
|| value.get("devDependencies").is_some()
|| value.get("scripts").is_some()
{
return Err(web_project_bad_request(
"P1 不允许新增依赖或自定义 scripts",
None,
));
}
Ok(())
}
fn media_type_for_path(path: &str) -> &'static str {
if path.ends_with(".tsx") || path.ends_with(".ts") {
"text/typescript"
} else if path.ends_with(".css") {
"text/css"
} else if path.ends_with(".html") {
"text/html"
} else if path.ends_with(".json") {
"application/json"
} else {
"text/plain"
}
}
fn project_from_record(record: WebProjectRecord) -> shared_contracts::web_project::WebProject {
shared_contracts::web_project::WebProject {
project_id: record.project_id,
owner_user_id: record.owner_user_id,
title: record.title,
template_key: record.template_key,
active_snapshot_id: record.active_snapshot_id,
active_preview_build_id: record.active_preview_build_id,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn snapshot_from_record(record: WebProjectSnapshotRecord) -> WebProjectSnapshot {
WebProjectSnapshot {
snapshot_id: record.snapshot_id,
project_id: record.project_id,
owner_user_id: record.owner_user_id,
parent_snapshot_id: record.parent_snapshot_id,
template_key: record.template_key,
files: record
.files
.into_iter()
.map(web_project_file_from_record)
.collect(),
patch_summary: record.patch_summary,
created_by: record.created_by,
created_at: record.created_at,
}
}
fn build_from_record(record: WebProjectPreviewBuildRecord) -> WebProjectPreviewBuild {
WebProjectPreviewBuild {
job_id: record.job_id,
project_id: record.project_id,
snapshot_id: record.snapshot_id,
owner_user_id: record.owner_user_id,
status: build_status_from_record(record.status.as_str()),
logs: record.logs,
artifact_id: record.artifact_id,
preview_token_id: record.preview_token_id,
preview_url: record.preview_url,
error_summary: record.error_summary,
created_at: record.created_at,
started_at: record.started_at,
finished_at: record.finished_at,
updated_at: record.updated_at,
}
}
fn web_project_file_to_record(file: WebProjectFile) -> WebProjectFileRecord {
WebProjectFileRecord {
path: file.path,
content: file.content,
media_type: file.media_type,
encoding: file.encoding,
size_bytes: file.size_bytes,
}
}
fn web_project_file_from_record(record: WebProjectFileRecord) -> WebProjectFile {
WebProjectFile {
path: record.path,
content: record.content,
media_type: record.media_type,
encoding: record.encoding,
size_bytes: record.size_bytes,
}
}
fn build_status_from_record(status: &str) -> WebProjectPreviewBuildStatus {
match status {
"running" => WebProjectPreviewBuildStatus::Running,
"succeeded" => WebProjectPreviewBuildStatus::Succeeded,
"failed" => WebProjectPreviewBuildStatus::Failed,
"cancelled" => WebProjectPreviewBuildStatus::Cancelled,
"expired" => WebProjectPreviewBuildStatus::Expired,
"stale" => WebProjectPreviewBuildStatus::Stale,
_ => WebProjectPreviewBuildStatus::Queued,
}
}
fn spawn_preview_build_task(
state: AppState,
owner_user_id: String,
build: WebProjectPreviewBuild,
build_slot: WebProjectPreviewBuildSlot,
) {
tokio::spawn(async move {
let _build_slot = build_slot;
if let Err(error) =
run_preview_build_task(state.clone(), owner_user_id.clone(), build.clone()).await
{
let fallback = mark_preview_build_failed(&state, &owner_user_id, build, error).await;
publish_build_event(&state, &fallback, fallback.error_summary.as_deref());
}
});
}
async fn run_preview_build_task(
state: AppState,
owner_user_id: String,
build: WebProjectPreviewBuild,
) -> Result<(), String> {
let started_at_micros = current_utc_micros();
let running = state
.spacetime_client()
.update_web_project_preview_build(
spacetime_client::WebProjectPreviewBuildUpdateRecordInput {
job_id: build.job_id.clone(),
owner_user_id: owner_user_id.clone(),
status: "running".to_string(),
logs: vec!["构建任务开始执行".to_string()],
artifact_id: None,
preview_token_id: None,
preview_url: None,
error_summary: None,
started_at_micros: Some(started_at_micros),
finished_at_micros: None,
updated_at_micros: started_at_micros,
},
)
.await
.map_err(|error| error.to_string())?;
let running_build = build_from_record(running.build);
publish_build_event(&state, &running_build, Some("构建任务开始执行"));
let snapshot = state
.spacetime_client()
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
project_id: build.project_id.clone(),
snapshot_id: Some(build.snapshot_id.clone()),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(|error| error.to_string())?
.snapshot;
let runner_output = invoke_runner_process(&state, &build, snapshot).await?;
let finished_at_micros = current_utc_micros();
let succeeded = runner_output.status == WebProjectBuildStatus::Succeeded;
let preview_token_id = succeeded.then(|| build_preview_token_id(finished_at_micros));
let preview_url = preview_token_id
.as_ref()
.map(|token| build_preview_url(&state.config.web_project_preview_public_base_url, token));
let mut logs = build.logs.clone();
logs.push("构建任务开始执行".to_string());
logs.extend(runner_output.logs);
let updated = state
.spacetime_client()
.update_web_project_preview_build(
spacetime_client::WebProjectPreviewBuildUpdateRecordInput {
job_id: build.job_id.clone(),
owner_user_id,
status: if succeeded { "succeeded" } else { "failed" }.to_string(),
logs,
artifact_id: runner_output.artifact_id,
preview_token_id,
preview_url,
error_summary: runner_output.error_summary,
started_at_micros: Some(started_at_micros),
finished_at_micros: Some(finished_at_micros),
updated_at_micros: finished_at_micros,
},
)
.await
.map_err(|error| error.to_string())?;
let updated_build = build_from_record(updated.build);
publish_build_event(
&state,
&updated_build,
updated_build
.error_summary
.as_deref()
.or(Some(if succeeded {
"构建完成"
} else {
"构建失败"
})),
);
Ok(())
}
async fn mark_preview_build_failed(
state: &AppState,
owner_user_id: &str,
build: WebProjectPreviewBuild,
error: String,
) -> WebProjectPreviewBuild {
let now = current_utc_micros();
let mut logs = build.logs.clone();
logs.push("构建任务失败".to_string());
logs.push(format!("runner: {error}"));
match state
.spacetime_client()
.update_web_project_preview_build(
spacetime_client::WebProjectPreviewBuildUpdateRecordInput {
job_id: build.job_id.clone(),
owner_user_id: owner_user_id.to_string(),
status: "failed".to_string(),
logs,
artifact_id: None,
preview_token_id: None,
preview_url: None,
error_summary: Some(error.clone()),
started_at_micros: None,
finished_at_micros: Some(now),
updated_at_micros: now,
},
)
.await
{
Ok(updated) => build_from_record(updated.build),
Err(update_error) => {
let mut logs = build.logs.clone();
logs.push(format!("runner: {error}"));
logs.push(format!("runner: 构建失败状态写回失败:{update_error}"));
WebProjectPreviewBuild {
status: WebProjectPreviewBuildStatus::Failed,
logs,
error_summary: Some(error),
finished_at: Some(shared_kernel::format_timestamp_micros(now)),
updated_at: shared_kernel::format_timestamp_micros(now),
..build
}
}
}
}
async fn invoke_runner_process(
state: &AppState,
build: &WebProjectPreviewBuild,
snapshot: WebProjectSnapshotRecord,
) -> Result<WebProjectBuildOutput, String> {
let runner_bin = resolve_runner_binary(state)?;
let input = WebProjectBuildInput {
job_id: build.job_id.clone(),
project_id: build.project_id.clone(),
snapshot_id: build.snapshot_id.clone(),
files: snapshot
.files
.into_iter()
.map(|file| WebProjectRunnerFile {
path: file.path,
content: file.content,
})
.collect(),
artifact_root: state.config.web_project_artifact_root.clone(),
};
let input_json = serde_json::to_vec(&input).map_err(|error| error.to_string())?;
let mut command = Command::new(&runner_bin);
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.map_err(|error| format!("启动 web-project-runner 失败:{error}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(&input_json)
.await
.map_err(|error| format!("写入 runner 输入失败:{error}"))?;
}
let mut stdout = child
.stdout
.take()
.ok_or_else(|| "web-project-runner stdout 未打开".to_string())?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| "web-project-runner stderr 未打开".to_string())?;
let stdout_task = tokio::spawn(async move {
let mut bytes = Vec::new();
stdout
.read_to_end(&mut bytes)
.await
.map(|_| bytes)
.map_err(|error| error.to_string())
});
let stderr_task = tokio::spawn(async move {
let mut bytes = Vec::new();
stderr
.read_to_end(&mut bytes)
.await
.map(|_| bytes)
.map_err(|error| error.to_string())
});
let status = match tokio::time::timeout(WEB_PROJECT_RUNNER_TIMEOUT, child.wait()).await {
Ok(result) => result.map_err(|error| format!("等待 runner 退出失败:{error}"))?,
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
let _ = stdout_task.await;
let _ = stderr_task.await;
return Err("web-project-runner 执行超时".to_string());
}
};
let stdout = stdout_task
.await
.map_err(|error| format!("读取 runner stdout 任务失败:{error}"))?
.map_err(|error| format!("读取 runner stdout 失败:{error}"))?;
let stderr = stderr_task
.await
.map_err(|error| format!("读取 runner stderr 任务失败:{error}"))?
.map_err(|error| format!("读取 runner stderr 失败:{error}"))?;
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr);
return Ok(WebProjectBuildOutput {
job_id: build.job_id.clone(),
project_id: build.project_id.clone(),
snapshot_id: build.snapshot_id.clone(),
status: WebProjectBuildStatus::Failed,
artifact_id: None,
artifact_path: None,
error_summary: Some(format!("web-project-runner 退出失败:{}", stderr.trim())),
logs: vec![stderr.to_string()],
});
}
serde_json::from_slice::<WebProjectBuildOutput>(&stdout)
.map_err(|error| format!("runner 输出不是合法 JSON{error}"))
}
fn resolve_runner_binary(state: &AppState) -> Result<PathBuf, String> {
if let Some(path) = state.config.web_project_runner_bin.as_ref() {
return Ok(path.clone());
}
let current_exe = std::env::current_exe().map_err(|error| error.to_string())?;
let exe_name = if cfg!(windows) {
"web-project-runner.exe"
} else {
"web-project-runner"
};
Ok(current_exe
.parent()
.map(|parent| parent.join(exe_name))
.unwrap_or_else(|| PathBuf::from(exe_name)))
}
fn build_preview_token_id(issued_at_micros: i64) -> String {
format!("wpt_{}_{}", issued_at_micros, new_uuid_simple_string())
}
fn build_preview_url(base_url: &str, preview_token_id: &str) -> String {
format!("{}/p/{preview_token_id}/", base_url.trim_end_matches('/'))
}
fn publish_build_event(state: &AppState, build: &WebProjectPreviewBuild, message: Option<&str>) {
let event = WebProjectPreviewBuildEvent {
job_id: build.job_id.clone(),
status: build.status.clone(),
message: message.map(str::to_string),
build: Some(build.clone()),
};
if let Ok(payload) = serde_json::to_string(&event) {
let _ = state.web_project_build_updates().send(payload);
}
}
fn web_project_bad_request(message: &str, path: Option<&str>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "web-project",
"path": path,
"message": message,
}))
}
fn map_preview_build_slot_error(
error: WebProjectPreviewBuildSlotError,
project_id: &str,
) -> AppError {
let message = match error {
WebProjectPreviewBuildSlotError::GlobalLimit => "预览构建队列繁忙,请稍后重试",
WebProjectPreviewBuildSlotError::ProjectAlreadyRunning => "当前工程已有预览构建正在执行",
};
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_details(json!({
"provider": "web-project",
"projectId": project_id,
"message": message,
}))
}
fn map_web_project_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Procedure(message)
if message.contains("不存在") || message.contains("不属于当前项目") =>
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
StatusCode::FORBIDDEN
}
SpacetimeClientError::Procedure(message) if message.contains("已存在") => {
StatusCode::CONFLICT
}
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
_ => StatusCode::BAD_GATEWAY,
};
AppError::from_status(status).with_details(json!({
"provider": "web-project",
"message": error.to_string(),
}))
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use axum::http::StatusCode;
use shared_contracts::web_project::{WebProjectPatch, WebProjectPatchOperation};
use super::*;
#[test]
fn rename_file_rejects_existing_target_path() {
let base_files = vec![
build_text_file(
"src/App.tsx",
"export default function App() { return null; }\n",
)
.expect("source file should be valid"),
build_text_file("src/App.css", "body { margin: 0; }\n")
.expect("target file should be valid"),
];
let patch = WebProjectPatch {
operations: vec![WebProjectPatchOperation::RenameFile {
from_path: "src/App.tsx".to_string(),
to_path: "src/App.css".to_string(),
}],
};
let error = apply_web_project_patch(&base_files, &patch)
.expect_err("rename should reject existing target");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("目标文件已存在"));
}
}