Files
Genarrative/server-rs/crates/api-server/src/external_generation_worker.rs
T
kdletters 1f904d28e9
Project CI / Frontend tests (push) Successful in 4m2s
Project CI / Repository checks (push) Successful in 2m16s
Project CI / Backend tests (push) Successful in 10m14s
Project CI / Native shell tests (push) Failing after 19m54s
AGC 客户端 MCP 能力暴露 (#274)
## 目标
保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。

## 范围
- 客户端会话、项目文件、资源、画布、生成、预览等稳定能力
- 审核 Skill 的索引与按需指导资源
- 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界

## 明确不做
- 不替换客户端对话入口或 Codex app-server
- 不让客户端替 Codex 判断高层意图、完成状态或规划
- 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力

当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。

Reviewed-on: #274
2026-09-08 22:01:29 +08:00

2929 lines
110 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::{
future::Future,
io,
pin::Pin,
time::{Duration, Instant},
};
use axum::Json;
use serde_json::{Value, json};
use shared_kernel::offset_datetime_to_unix_micros;
use spacetime_client::{
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord,
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueWakeSubscription,
};
use tokio::{
sync::{OwnedSemaphorePermit, Semaphore},
task::{JoinHandle, JoinSet},
time::sleep,
};
use tracing::{error, info, warn};
const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048;
// provider 必须先结束,给失败审计、计费结算和队列终态写回保留有效 lease 内的收尾窗口。
const EXTERNAL_GENERATION_WORKER_TERMINAL_WRITE_RESERVE: Duration = Duration::from_secs(60);
const EDITOR_GENERATION_SLICE_WARNING_PREFIX: &str = "图集已生成,但自动拆分未完成:";
const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str =
"自动拆分未完成(告警详情含内联媒体引用,已省略)";
use crate::{
asset_billing::with_external_generation_billing_attempt_context,
character_animation_assets::{
generate_editor_character_animation_for_owner, generate_editor_video_for_owner,
},
config::AppConfig,
editor_generation_queue::{
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_ICON_SPEC_GENERATION_JOB_KIND,
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND,
EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX,
},
editor_project::{
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION,
EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationOperationContext,
EditorGenerationPhaseReporter, EditorGenerationQueueResultContext,
EditorImageEditQueuePayload, EditorImageEditRequest, EditorImageEditResolvedSource,
EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
compact_editor_generation_result, edit_editor_image_for_owner_with_source_snapshot,
extract_editor_ui_design_assets_for_owner, generate_editor_image_for_owner,
remove_editor_image_background_for_owner,
},
editor_project_icon::{
EditorIconSpecGenerationRequest, EditorIconSpritesheetGenerationRequest,
generate_editor_icon_spritesheet_for_owner, generate_icon_spec_for_owner,
},
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
},
};
#[cfg(any())]
use crate::{
puzzle::{
ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload,
PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload,
execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job,
execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim,
},
puzzle_clear::{
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND, PuzzleClearCompileDraftWorkerPayload,
execute_puzzle_clear_compile_draft_worker_job,
},
state::PuzzleApiState,
wooden_fish::{
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload,
execute_wooden_fish_generate_image_assets_worker_job,
},
};
#[cfg(any())]
pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
#[cfg(any())]
pub(crate) const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
#[cfg(any())]
pub(crate) const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<(), io::Error> {
let worker_id = state.config.external_generation_worker_id.clone();
let concurrency = state.config.external_generation_worker_concurrency.max(1);
let poll_interval = state.config.external_generation_worker_poll_interval;
let lease = state.config.external_generation_worker_lease;
// 超时任务不能立即取消(在途 procedure 仍可能写回),因此执行容量必须同时
// 约束 active 与 detached work;否则每次超时都会释放 tasks 槽位,实际内存占用
// 会超过配置并发。
let work_slots = std::sync::Arc::new(Semaphore::new(concurrency));
let mut tasks = JoinSet::new();
let mut shutdown = external_generation_worker_shutdown_signal();
let mut queue_wake = None;
info!(
worker_id,
concurrency,
poll_interval_ms = poll_interval.as_millis(),
lease_seconds = lease.as_secs(),
job_timeout_seconds = state
.config
.external_generation_worker_job_timeout
.as_secs(),
long_job_timeout_seconds = state
.config
.external_generation_worker_long_job_timeout
.as_secs(),
"external generation worker 已启动"
);
loop {
// 持续有队列任务时不会进入等待分支,因此必须在每轮主动回收已完成的
// JoinHandle;否则 permit 虽已归还,JoinSet 仍会保留每个历史任务的句柄。
reap_finished_external_generation_worker_tasks(&mut tasks);
ensure_external_generation_queue_wake_subscription(&state, &mut queue_wake).await;
while work_slots.available_permits() == 0 {
tokio::select! {
_ = shutdown.as_mut() => {
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
permit = work_slots.clone().acquire_owned() => {
if permit.is_err() {
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
// 只用 acquire 作为容量变化唤醒信号,许可立即归还;真正领取任务
// 时在下方按返回的 job 数量逐个 try_acquire。
}
}
}
let available = work_slots.available_permits().max(1);
let now_micros = current_utc_micros();
let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease));
let claim_jobs = state.spacetime_client().claim_external_generation_jobs(
ExternalGenerationJobClaimRecordInput {
worker_id: worker_id.clone(),
limit: available.min(u32::MAX as usize) as u32,
lease_expires_at_micros,
claimed_at_micros: now_micros,
},
);
tokio::pin!(claim_jobs);
let jobs = match tokio::select! {
_ = shutdown.as_mut() => {
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
result = &mut claim_jobs => result
} {
Ok(jobs) => jobs,
Err(error) => {
error!(error = %error, "领取外部生成任务失败,等待下一轮重试");
if await_one_task_or_queue_wake_or_sleep_or_shutdown(
&mut tasks,
&mut queue_wake,
sleep(poll_interval),
&mut shutdown,
)
.await
{
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
continue;
}
};
if jobs.is_empty() {
if await_one_task_or_queue_wake_or_sleep_or_shutdown(
&mut tasks,
&mut queue_wake,
sleep(poll_interval),
&mut shutdown,
)
.await
{
drain_external_generation_worker_tasks(&mut tasks).await;
return Ok(());
}
continue;
}
for job in jobs {
let state = state.clone();
let worker_id = worker_id.clone();
let permit = work_slots
.clone()
.try_acquire_owned()
.expect("claimed job must have an execution capacity permit");
tasks.spawn(async move {
if let Err(error) =
process_external_generation_job(state, worker_id, lease, job, permit).await
{
error!(error = %error, "external generation worker 执行任务失败");
}
});
}
}
}
async fn ensure_external_generation_queue_wake_subscription(
state: &AppState,
queue_wake: &mut Option<ExternalGenerationQueueWakeSubscription>,
) {
if queue_wake.is_some() {
return;
}
match state
.spacetime_client()
.subscribe_external_generation_queue_wake()
.await
{
Ok(subscription) => {
*queue_wake = Some(subscription);
info!("external generation worker 已订阅队列变更唤醒");
}
Err(error) => {
warn!(error = %error, "external generation worker 订阅队列变更失败,暂时使用间隔兜底");
}
}
}
type ExternalGenerationShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
fn external_generation_worker_shutdown_signal() -> ExternalGenerationShutdownSignal {
Box::pin(async {
wait_for_external_generation_worker_shutdown_signal().await;
})
}
#[cfg(unix)]
async fn wait_for_external_generation_worker_shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate()).ok();
tokio::select! {
result = tokio::signal::ctrl_c() => {
if let Err(error) = result {
warn!(error = %error, "external generation worker 监听 SIGINT 失败");
}
}
_ = async {
if let Some(sigterm) = sigterm.as_mut() {
sigterm.recv().await;
} else {
std::future::pending::<()>().await;
}
} => {}
}
}
#[cfg(not(unix))]
async fn wait_for_external_generation_worker_shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
warn!(error = %error, "external generation worker 监听 Ctrl-C 失败");
}
}
async fn await_worker_task(tasks: &mut JoinSet<()>) {
if let Some(result) = tasks.join_next().await
&& let Err(error) = result
{
error!(error = %error, "external generation worker 子任务 panic");
}
}
fn reap_finished_external_generation_worker_tasks(tasks: &mut JoinSet<()>) {
while let Some(result) = tasks.try_join_next() {
if let Err(error) = result {
error!(error = %error, "external generation worker 子任务 panic");
}
}
}
async fn await_one_task_or_queue_wake_or_sleep_or_shutdown(
tasks: &mut JoinSet<()>,
queue_wake: &mut Option<ExternalGenerationQueueWakeSubscription>,
sleeper: impl Future<Output = ()>,
shutdown: &mut ExternalGenerationShutdownSignal,
) -> bool {
let queue_changed = await_external_generation_queue_wake(queue_wake);
tokio::pin!(queue_changed);
tokio::pin!(sleeper);
if tasks.is_empty() {
tokio::select! {
_ = shutdown.as_mut() => true,
_ = &mut queue_changed => false,
_ = &mut sleeper => false,
}
} else {
tokio::select! {
_ = shutdown.as_mut() => true,
_ = &mut queue_changed => false,
_ = &mut sleeper => false,
result = tasks.join_next() => {
if let Some(Err(error)) = result {
error!(error = %error, "external generation worker 子任务 panic");
}
false
}
}
}
}
async fn await_external_generation_queue_wake(
queue_wake: &mut Option<ExternalGenerationQueueWakeSubscription>,
) {
let result = if let Some(subscription) = queue_wake.as_mut() {
subscription.changed().await
} else {
std::future::pending().await
};
if let Err(error) = result {
warn!(error = %error, "external generation worker 队列变更订阅已失效,等待兜底间隔后重连");
*queue_wake = None;
std::future::pending::<()>().await;
}
}
async fn drain_external_generation_worker_tasks(tasks: &mut JoinSet<()>) {
info!(
in_flight_jobs = tasks.len(),
"external generation worker 收到停机信号,停止领取新任务并等待当前任务完成"
);
while !tasks.is_empty() {
await_worker_task(tasks).await;
}
info!("external generation worker 已完成优雅停机");
}
async fn process_external_generation_job(
state: AppState,
worker_id: String,
lease: Duration,
job: ExternalGenerationJobRecord,
permit: OwnedSemaphorePermit,
) -> Result<(), String> {
let heartbeat_interval = external_generation_worker_heartbeat_interval(lease);
let job_timeout = external_generation_worker_job_timeout(&state.config, job.job_kind.as_str());
let (job_deadline, provider_deadline) =
external_generation_worker_deadlines(Instant::now(), job_timeout);
let billing_price_mud_points = match external_generation_billing_price_mud_points(&job) {
Ok(price_mud_points) => price_mud_points,
Err(message) => {
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let billing_claim_attempt = match external_generation_billing_claim_attempt(&job) {
Ok(claim_attempt) => claim_attempt,
Err(message) => {
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
// work 单独 spawn:已发出的 SpacetimeDB procedure 无法通过 drop 客户端 future 撤销,
// 若超时时 drop 在途写回再抢先写失败态,可能出现“业务写回已提交、任务却被标记失败
// 并退款”的不一致。因此超时/续租失败时不取消 work,也不在客户端写失败态,交由服务端
// lease fencing 仲裁:写回在租约有效期内到达则任务照常完成,否则被拒绝;租约到期后
// 任务被重新认领,attempt 耗尽时由认领事务原子地标记失败并结算退款。
let mut work_handle = tokio::spawn(with_external_generation_billing_attempt_context(
job.job_id.clone(),
billing_claim_attempt,
billing_price_mud_points,
process_external_generation_job_once(
state.clone(),
worker_id.clone(),
job.clone(),
provider_deadline,
),
));
let heartbeat =
maintain_external_generation_job_lease(&state, &worker_id, &job, lease, heartbeat_interval);
match await_external_generation_job_execution(
join_external_generation_work(&mut work_handle),
heartbeat,
job_deadline,
)
.await
{
ExternalGenerationJobExecutionOutcome::Finished(result) => result,
ExternalGenerationJobExecutionOutcome::TimedOut => {
let message = external_generation_worker_timeout_message(&job, job_timeout);
warn!(
job_id = %job.job_id,
job_kind = %job.job_kind,
timeout_seconds = job_timeout.as_secs(),
"external generation worker 任务超过执行预算,停止续租并保留 worker 槽位,在途执行交由租约仲裁"
);
detach_external_generation_work_until_lease_expiry(
work_handle,
&job,
lease,
"任务超过执行预算",
Some(permit),
);
Err(message)
}
ExternalGenerationJobExecutionOutcome::LeaseRenewalFailed(error) => {
detach_external_generation_work_until_lease_expiry(
work_handle,
&job,
lease,
"任务租约续期失败",
Some(permit),
);
Err(error)
}
}
}
async fn join_external_generation_work(
work_handle: &mut JoinHandle<Result<(), String>>,
) -> Result<(), String> {
match work_handle.await {
Ok(result) => result,
Err(join_error) => Err(format!("external generation work 任务 panic{join_error}")),
}
}
/// 停止等待 work 后不能直接取消它:在途 procedure 可能已在服务端提交。让它继续跑完当前
/// 尝试,写回由 lease fencing 裁决是否生效。等到租约必然过期(2 倍租约时长,此后一切
/// 围栏写回都会被拒绝)仍未结束的任务才安全取消,取消触发的计费补偿退款按 attempt
/// 账本幂等,与队列侧耗尽结算不会重复。
fn detach_external_generation_work_until_lease_expiry(
mut work_handle: JoinHandle<Result<(), String>>,
job: &ExternalGenerationJobRecord,
lease: Duration,
reason: &'static str,
permit: Option<OwnedSemaphorePermit>,
) {
let job_id = job.job_id.clone();
let job_kind = job.job_kind.clone();
let grace = lease.saturating_mul(2);
tokio::spawn(async move {
match tokio::time::timeout(grace, &mut work_handle).await {
Ok(Ok(Ok(()))) => info!(
job_id = %job_id,
job_kind = %job_kind,
reason,
"external generation worker 脱管任务已在租约仲裁窗口内完成写回"
),
Ok(Ok(Err(error))) => info!(
job_id = %job_id,
job_kind = %job_kind,
reason,
error = %error,
"external generation worker 脱管任务在租约仲裁窗口内以失败结束"
),
Ok(Err(join_error)) => error!(
job_id = %job_id,
job_kind = %job_kind,
reason,
error = %join_error,
"external generation worker 脱管任务 panic"
),
Err(_) => {
work_handle.abort();
// 仅调用 abort 不会从 JoinHandle/JoinSet 中消费完成结果;等待被取消
// 的 handle,确保 permit 与任务句柄在同一生命周期内一起释放。
let _ = work_handle.await;
warn!(
job_id = %job_id,
job_kind = %job_kind,
reason,
grace_ms = grace.as_millis() as u64,
"external generation worker 脱管任务超过租约仲裁窗口仍未结束,已取消;其后续写回将被 lease fencing 拒绝"
);
}
}
// 保持执行许可直到 work 真正结束或被取消,避免超时任务脱管后继续
// 累积图片/音频响应占用。
drop(permit);
});
}
#[derive(Debug, PartialEq, Eq)]
enum ExternalGenerationJobExecutionOutcome {
Finished(Result<(), String>),
TimedOut,
LeaseRenewalFailed(String),
}
async fn await_external_generation_job_execution<W, H>(
work: W,
heartbeat: H,
job_deadline: Instant,
) -> ExternalGenerationJobExecutionOutcome
where
W: Future<Output = Result<(), String>>,
H: Future<Output = Result<(), String>>,
{
tokio::pin!(work);
tokio::pin!(heartbeat);
let job_deadline_sleep = tokio::time::sleep_until(job_deadline.into());
tokio::pin!(job_deadline_sleep);
tokio::select! {
biased;
result = &mut work => ExternalGenerationJobExecutionOutcome::Finished(result),
_ = &mut job_deadline_sleep => ExternalGenerationJobExecutionOutcome::TimedOut,
result = &mut heartbeat => match result {
Ok(()) => unreachable!("external generation heartbeat monitor should not finish"),
Err(error) => ExternalGenerationJobExecutionOutcome::LeaseRenewalFailed(error),
},
}
}
async fn maintain_external_generation_job_lease(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
lease: Duration,
heartbeat_interval: Duration,
) -> Result<(), String> {
loop {
sleep(heartbeat_interval).await;
renew_job_lease(state, worker_id, job, lease).await?;
}
}
async fn process_external_generation_job_once(
state: AppState,
worker_id: String,
job: ExternalGenerationJobRecord,
provider_deadline: Instant,
) -> Result<(), String> {
match job.job_kind.as_str() {
#[cfg(any())]
PUZZLE_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼图生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
let puzzle_state = PuzzleApiState::from_ref(&state);
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
match execute_puzzle_compile_draft_worker_job(
&puzzle_state,
&request_context,
payload.clone(),
write_guard,
)
.await
{
Ok(session) => {
let result = complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"progressPercent": session.progress_percent,
})
.to_string(),
),
)
.await;
if result.is_ok() {
release_puzzle_compile_background_claim(&puzzle_state, &payload);
}
result
}
Err(error) => {
let message = error.body_text();
let should_release_claim = error.should_fail_queue_job();
let result = fail_queue_job_after_worker_error(
&state, &worker_id, &job, &error, &message,
)
.await;
if result.is_ok() && should_release_claim {
release_puzzle_compile_background_claim(&puzzle_state, &payload);
}
result?;
Err(message)
}
}
}
#[cfg(any())]
PUZZLE_GENERATE_IMAGES_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleGenerateImagesWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼图关卡图片生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
let puzzle_state = PuzzleApiState::from_ref(&state);
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
match execute_puzzle_generate_images_worker_job(
&puzzle_state,
&request_context,
payload,
write_guard,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"progressPercent": session.progress_percent,
})
.to_string(),
),
)
.await
}
Err(error) => {
let message = error.body_text();
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
.await?;
Err(message)
}
}
}
#[cfg(any())]
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleGenerateUiBackgroundWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼图 UI 背景图生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
let puzzle_state = PuzzleApiState::from_ref(&state);
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
match execute_puzzle_generate_ui_background_worker_job(
&puzzle_state,
&request_context,
payload,
write_guard,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"progressPercent": session.progress_percent,
})
.to_string(),
),
)
.await
}
Err(error) => {
let message = error.body_text();
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
.await?;
Err(message)
}
}
}
#[cfg(any())]
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND => {
let payload = match serde_json::from_str::<PuzzleClearCompileDraftWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("拼消消生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_puzzle_clear_compile_draft_worker_job(&state, &request_context, payload)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
#[cfg(any())]
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND => {
let payload = match serde_json::from_str::<WoodenFishGenerateImageAssetsWorkerPayload>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("敲木鱼图片生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
);
match execute_wooden_fish_generate_image_assets_worker_job(
&state,
&request_context,
payload,
)
.await
{
Ok(session) => {
complete_job(
&state,
&worker_id,
&job,
Some(
json!({
"sessionId": session.session_id,
"status": session.status,
})
.to_string(),
),
)
.await
}
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_IMAGE_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<EditorImageGenerationRequest>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布生图任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_editor_image_for_owner(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload,
)
.await
{
Ok(_) => Ok(()),
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_IMAGE_EDIT_JOB_KIND => {
let payload = match parse_editor_image_edit_worker_payload(&job) {
Ok(payload) => payload,
Err(message) => {
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match edit_editor_image_for_owner_with_source_snapshot(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload.request,
payload.source.as_ref(),
)
.await
{
Ok(_) => Ok(()),
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_BACKGROUND_REMOVAL_JOB_KIND => {
let mut payload = match serde_json::from_str::<EditorBackgroundRemovalRequest>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布去除背景任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
payload.task_id.get_or_insert_with(|| job.job_id.clone());
let request_context = worker_request_context(&job, provider_deadline);
match remove_editor_image_background_for_owner(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload,
)
.await
{
Ok(_) => Ok(()),
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<EditorIconSpritesheetGenerationRequest>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布图标素材任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_editor_icon_spritesheet_for_owner(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload,
)
.await
{
Ok(_) => Ok(()),
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_ICON_SPEC_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<EditorIconSpecGenerationRequest>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布图标规范任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_icon_spec_for_owner(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload,
)
.await
{
Ok(_) => Ok(()),
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND => {
let payload = match serde_json::from_str::<EditorUiDesignAssetExtractionRequest>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布 UI 素材提取任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match extract_editor_ui_design_assets_for_owner(
&state,
&request_context,
editor_generation_worker_caller(&worker_id, &job)?,
payload,
)
.await
{
Ok(_) => Ok(()),
Err(error) => {
let message = error.body_text();
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<
shared_contracts::assets::EditorCharacterAnimationGenerateRequest,
>(job.request_payload_json.as_str())
{
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布角色动作任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_editor_character_animation_for_owner(
state.clone(),
request_context,
editor_generation_worker_caller(&worker_id, &job)?,
Ok(Json(payload)),
)
.await
{
Ok(_) => Ok(()),
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_VIDEO_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<
shared_contracts::assets::EditorVideoGenerateRequest,
>(job.request_payload_json.as_str())
{
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布视频生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_editor_video_for_owner(
state.clone(),
request_context,
editor_generation_worker_caller(&worker_id, &job)?,
Ok(Json(payload)),
)
.await
{
Ok(_) => Ok(()),
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<
shared_contracts::assets::EditorSoundEffectGenerateRequest,
>(job.request_payload_json.as_str())
{
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布音效生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_editor_sound_effect_for_owner(
state.clone(),
request_context,
editor_generation_worker_caller(&worker_id, &job)?,
Ok(Json(payload)),
)
.await
{
Ok(_) => Ok(()),
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND => {
let payload = match serde_json::from_str::<
shared_contracts::assets::EditorBackgroundMusicGenerateRequest,
>(job.request_payload_json.as_str())
{
Ok(payload) => payload,
Err(error) => {
let message = format!("图片画布背景音乐生成任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
};
let request_context = worker_request_context(&job, provider_deadline);
match generate_editor_background_music_for_owner(
state.clone(),
request_context,
editor_generation_worker_caller(&worker_id, &job)?,
Ok(Json(payload)),
)
.await
{
Ok(_) => Ok(()),
Err(response) => {
let message = response_error_message(response).await;
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
unknown => {
warn!(
job_id = job.job_id,
job_kind = unknown,
"external generation worker 收到暂不支持的任务类型"
);
fail_job(
&state,
&worker_id,
&job,
format!("暂不支持的外部生成任务类型:{unknown}"),
)
.await
}
}
}
async fn response_error_message(response: axum::response::Response) -> String {
use axum::body::to_bytes;
let status = response.status();
let body_bytes = match to_bytes(response.into_body(), 64 * 1024).await {
Ok(bytes) => bytes,
Err(error) => {
return format!("外部生成任务失败:{status},响应读取失败:{error}");
}
};
let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
if body_text.is_empty() {
return format!("外部生成任务失败:{status}");
}
if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(&body_text)
&& let Some(message) = extract_api_error_display_message(&body_json)
{
return message;
}
body_text
}
fn extract_api_error_display_message(body_json: &serde_json::Value) -> Option<String> {
let error = body_json.get("error")?;
error
.get("details")
.and_then(|details| {
read_trimmed_json_string(details.get("reason"))
.or_else(|| read_trimmed_json_string(details.get("message")))
})
.or_else(|| read_trimmed_json_string(error.get("message")))
}
fn read_trimmed_json_string(value: Option<&serde_json::Value>) -> Option<String> {
value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(any())]
async fn fail_queue_job_after_worker_error(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
error: &crate::puzzle::PuzzleExternalGenerationWorkerError,
message: &str,
) -> Result<(), String> {
if error.should_fail_queue_job() {
return fail_job(state, worker_id, job, message.to_string()).await;
}
warn!(
job_id = job.job_id,
job_kind = job.job_kind,
"external generation worker 业务失败态尚未写回,保留任务租约等待后续重试"
);
Ok(())
}
fn worker_request_context(
job: &ExternalGenerationJobRecord,
provider_deadline: Instant,
) -> RequestContext {
RequestContext::new(
format!("external-generation-worker-{}", job.job_id),
format!("external-generation-worker {}", job.job_kind),
std::time::Duration::ZERO,
false,
)
.with_external_call_deadline(provider_deadline)
}
struct ParsedEditorImageEditWorkerPayload {
request: EditorImageEditRequest,
source: Option<EditorImageEditResolvedSource>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyEditorImageEditWorkerPayload {
prompt: String,
source_image_src: String,
size: Option<String>,
model: Option<String>,
aspect_ratio: Option<String>,
image_size: Option<String>,
reference_image_srcs: Option<Vec<String>>,
project_id: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
source_resource_id: Option<String>,
target_layer_id: Option<String>,
canvas_completion: Option<shared_contracts::assets::EditorCanvasGenerationCompletionPayload>,
}
fn parse_editor_image_edit_worker_payload(
job: &ExternalGenerationJobRecord,
) -> Result<ParsedEditorImageEditWorkerPayload, String> {
if let Ok(payload) =
serde_json::from_str::<EditorImageEditQueuePayload>(job.request_payload_json.as_str())
{
if payload.version != EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION {
return Err(format!(
"图片画布改图任务载荷版本不受支持:{}",
payload.version
));
}
return Ok(ParsedEditorImageEditWorkerPayload {
request: payload.request,
source: Some(payload.source),
});
}
let legacy: LegacyEditorImageEditWorkerPayload =
serde_json::from_str(job.request_payload_json.as_str())
.map_err(|error| format!("图片画布改图任务参数解析失败:{error}"))?;
let source_reference_id = legacy
.source_resource_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| legacy.source_image_src.trim())
.to_string();
if source_reference_id.is_empty() {
return Err("历史图片画布改图任务缺少可迁移的业务引用 ID".to_string());
}
Ok(ParsedEditorImageEditWorkerPayload {
request: EditorImageEditRequest {
prompt: legacy.prompt,
source_reference_id,
size: legacy.size,
model: legacy.model,
aspect_ratio: legacy.aspect_ratio,
image_size: legacy.image_size,
reference_image_srcs: legacy.reference_image_srcs,
project_id: legacy.project_id,
generation_inputs: legacy.generation_inputs,
asset_folder_id: legacy.asset_folder_id,
asset_label: legacy.asset_label,
target_layer_id: legacy.target_layer_id,
canvas_completion: legacy.canvas_completion,
},
source: None,
})
}
fn editor_generation_worker_caller(
worker_id: &str,
job: &ExternalGenerationJobRecord,
) -> Result<EditorGenerationCaller, String> {
Ok(EditorGenerationCaller {
owner_user_id: job.owner_user_id.clone(),
audit_subject_user_id: Some(job.owner_user_id.clone()),
audit_project_id: Some(job.source_entity_id.clone()),
phase_reporter: Some(editor_generation_phase_reporter(worker_id, job)?),
operation: Some(EditorGenerationOperationContext {
operation_kind: job.job_kind.clone(),
operation_id: job.job_id.clone(),
operation_fingerprint:
shared_contracts::editor_generation::editor_generation_request_fingerprint(
job.job_kind.as_str(),
job.request_payload_json.as_str(),
),
worker_id: Some(worker_id.to_string()),
lease_token: Some(require_job_lease_token(job)?),
queue_result_context: Some(EditorGenerationQueueResultContext::from_job(job)),
}),
})
}
fn editor_generation_phase_reporter(
worker_id: &str,
job: &ExternalGenerationJobRecord,
) -> Result<EditorGenerationPhaseReporter, String> {
Ok(EditorGenerationPhaseReporter::new(
job.job_id.clone(),
worker_id.to_string(),
require_job_lease_token(job)?,
))
}
async fn complete_editor_generation_job(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
response: Value,
) -> Result<(), String> {
complete_job(
state,
worker_id,
job,
Some(editor_generation_result_payload_json(job, &response)),
)
.await
}
fn editor_generation_result_payload_json(
job: &ExternalGenerationJobRecord,
response: &Value,
) -> String {
let mut payload = json!({
"sourceModule": job.source_module.clone(),
"sourceEntityId": job.source_entity_id.clone(),
});
if is_editor_agent_generation_job(job)
&& let Some(object) = payload.as_object_mut()
{
// The Agent needs this compact result to restore its tool-call card. Other jobs keep
// master's metadata-only completion payload to avoid turning the queue into an asset API.
object.insert(
// TODO extract const
"editor-agent-tool-call-result".to_string(),
compact_editor_generation_result(response.clone()),
);
}
if is_result_recovery_generation_job(job)
&& let Some(object) = payload.as_object_mut()
{
object.insert(
"result".to_string(),
compact_external_api_generation_result(response.clone()),
);
}
if let Some(warning) = extract_editor_generation_warning(response)
&& let Some(object) = payload.as_object_mut()
{
object.insert("warning".to_string(), warning);
}
payload.to_string()
}
fn is_result_recovery_generation_job(job: &ExternalGenerationJobRecord) -> bool {
let dedupe_key = job.dedupe_key.trim();
dedupe_key.starts_with("external-api-generation:")
|| dedupe_key.starts_with(&format!("{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:"))
}
fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
job.dedupe_key.trim().starts_with("editor-agent:")
}
fn compact_external_api_generation_result(result: Value) -> Value {
let mut result = result.get("data").cloned().unwrap_or(result);
let Some(object) = result.as_object_mut() else {
return Value::Null;
};
object.retain(|key, _| {
matches!(
key.as_str(),
"ok" | "imageSrc"
| "videoSrc"
| "audioSrc"
| "previewVideoPath"
| "thumbnailSrc"
| "objectKey"
| "assetObjectId"
| "width"
| "height"
| "sourceType"
| "model"
| "taskId"
| "durationSeconds"
| "loop"
| "resolution"
| "priceMudPoints"
| "audioKind"
| "spritesheetImageSrc"
| "spritesheetWidth"
| "spritesheetHeight"
| "iconImageSrcs"
| "sliceLayout"
| "sliceCount"
| "frames"
| "frameCount"
| "frameWidth"
| "frameHeight"
| "fps"
| "resource"
| "asset"
| "spritesheetResource"
| "spritesheetAsset"
| "warning"
| "sliceWarning"
)
});
for field in ["resource", "spritesheetResource"] {
if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) {
compact_external_generation_resource(resource);
}
}
for field in ["asset", "spritesheetAsset"] {
if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) {
compact_external_generation_asset(asset);
}
}
if let Some(icons) = object
.get_mut("iconImageSrcs")
.and_then(Value::as_array_mut)
{
for icon in icons {
let Some(icon) = icon.as_object_mut() else {
continue;
};
icon.retain(|key, _| {
matches!(
key.as_str(),
"name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset"
)
});
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
compact_external_generation_resource(resource);
}
if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) {
compact_external_generation_asset(asset);
}
remove_unstable_external_generation_media_fields(icon);
}
}
if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) {
for frame in frames {
let Some(frame) = frame.as_object_mut() else {
continue;
};
frame.retain(|key, _| {
matches!(
key.as_str(),
"frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height"
)
});
remove_unstable_external_generation_media_fields(frame);
}
}
for field in ["warning", "sliceWarning"] {
if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut)
&& let Some(reason) = warning.get_mut("reason")
&& let Some(value) = reason.as_str()
{
*reason = Value::String(normalize_editor_generation_warning_reason(value));
}
}
remove_unstable_external_generation_media_fields(object);
result
}
fn compact_external_generation_resource(resource: &mut serde_json::Map<String, Value>) {
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId"
| "projectId"
| "objectKey"
| "assetObjectId"
| "imageSrc"
| "width"
| "height"
| "sourceType"
| "assetKind"
| "taskId"
| "sourceResourceId"
)
});
remove_unstable_external_generation_media_fields(resource);
}
fn compact_external_generation_asset(asset: &mut serde_json::Map<String, Value>) {
asset.retain(|key, _| {
matches!(
key.as_str(),
"assetId"
| "folderId"
| "objectKey"
| "assetObjectId"
| "imageSrc"
| "thumbnailSrc"
| "width"
| "height"
| "sourceType"
| "assetKind"
| "taskId"
)
});
remove_unstable_external_generation_media_fields(asset);
}
fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map<String, Value>) {
object.retain(|key, value| {
if !matches!(
key.as_str(),
"imageSrc"
| "videoSrc"
| "audioSrc"
| "previewVideoPath"
| "thumbnailSrc"
| "spritesheetImageSrc"
) {
return true;
}
value
.as_str()
.is_some_and(is_stable_external_generation_media_reference)
});
}
fn is_stable_external_generation_media_reference(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value.starts_with('/')
&& !value.starts_with("//")
&& !value.contains('?')
&& !value.contains('#')
&& !value.to_ascii_lowercase().starts_with("data:")
&& !value.to_ascii_lowercase().starts_with("blob:")
&& !value.to_ascii_lowercase().starts_with("http://")
&& !value.to_ascii_lowercase().starts_with("https://")
}
fn is_editor_internal_processing_model(model: &str) -> bool {
matches!(
model.trim().to_ascii_lowercase().as_str(),
"anime-seg"
| "bgfilter complex"
| "birefnet"
| "connected-components"
| "screen-color-keying"
| "segment-common-image"
)
}
fn extract_editor_generation_warning_fields(
warning: Option<&Value>,
is_slice_warning: bool,
) -> Option<(String, String)> {
let warning = warning?;
let code = warning.get("code")?.as_str()?.trim();
let reason = warning.get("reason")?.as_str()?.trim();
if code.is_empty() || reason.is_empty() {
return None;
}
let reason = if is_slice_warning {
format!("{EDITOR_GENERATION_SLICE_WARNING_PREFIX}{reason}")
} else {
reason.to_string()
};
Some((code.to_string(), reason))
}
fn extract_editor_generation_warning(response: &Value) -> Option<Value> {
let data = response.get("data").unwrap_or(response);
// 中文注释:风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存。
// 队列结果只有一个有界 warning 字段,因此按与 inline 响应相同的策略归一:
// code 不同时收敛为 multiple-generation-warningsreason 按“通用在前、拆分在后”
// 顺序拼接,再交给既有上界收敛,不允许其中任何一条被静默丢弃。
let common = extract_editor_generation_warning_fields(data.get("warning"), false);
let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true);
let (code, reason) = match (common, slice) {
(None, None) => return None,
(Some(warning), None) | (None, Some(warning)) => warning,
(Some((common_code, common_reason)), Some((slice_code, slice_reason))) => {
let code = if common_code == slice_code {
common_code
} else {
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string()
};
(code, format!("{common_reason} {slice_reason}"))
}
};
let reason = normalize_editor_generation_warning_reason(reason.as_str());
Some(json!({
"code": code,
"reason": reason,
}))
}
fn normalize_editor_generation_warning_reason(reason: &str) -> String {
let normalized = reason.to_ascii_lowercase();
if normalized.contains("data:")
|| normalized.contains("blob:")
|| normalized.contains("http://")
|| normalized.contains("https://")
|| normalized.contains("x-amz-")
|| normalized.contains("signature=")
{
return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string();
}
let mut chars = reason.chars();
let mut bounded = chars
.by_ref()
.take(MAX_EDITOR_GENERATION_WARNING_CHARS)
.collect::<String>();
if chars.next().is_some() {
bounded.push('…');
}
bounded
}
async fn complete_job(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
result_payload_json: Option<String>,
) -> Result<(), String> {
state
.spacetime_client()
.complete_external_generation_job(ExternalGenerationJobCompleteRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id.to_string(),
lease_token: require_job_lease_token(job)?,
result_payload_json,
completed_at_micros: current_utc_micros(),
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
async fn fail_job(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
error_message: String,
) -> Result<(), String> {
let now_micros = current_utc_micros();
state
.spacetime_client()
.fail_external_generation_job(ExternalGenerationJobFailRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id.to_string(),
lease_token: require_job_lease_token(job)?,
error_message,
retry_after_micros: now_micros.saturating_add(60_000_000),
failed_at_micros: now_micros,
// 退款由 SpacetimeDB 在验证 lease 后与失败终态同事务结算。
refund_ledger_id: None,
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
async fn renew_job_lease(
state: &AppState,
worker_id: &str,
job: &ExternalGenerationJobRecord,
lease: Duration,
) -> Result<(), String> {
let now_micros = current_utc_micros();
state
.spacetime_client()
.renew_external_generation_job_lease(ExternalGenerationJobRenewLeaseRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id.to_string(),
lease_token: require_job_lease_token(job)?,
lease_expires_at_micros: now_micros.saturating_add(duration_micros_i64(lease)),
renewed_at_micros: now_micros,
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
fn require_job_lease_token(job: &ExternalGenerationJobRecord) -> Result<String, String> {
job.lease_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| format!("external_generation_job {} 缺少 lease token", job.job_id))
}
#[cfg(any())]
fn build_external_generation_write_lease_guard(
worker_id: &str,
job: &ExternalGenerationJobRecord,
) -> Result<ExternalGenerationWriteLeaseGuard, String> {
Ok(ExternalGenerationWriteLeaseGuard::from_claimed_job(
job.job_id.clone(),
worker_id.to_string(),
require_job_lease_token(job)?,
))
}
fn duration_micros_i64(duration: Duration) -> i64 {
duration.as_micros().min(i64::MAX as u128) as i64
}
fn external_generation_billing_price_mud_points(
job: &ExternalGenerationJobRecord,
) -> Result<u32, String> {
u32::try_from(job.price_mud_points).map_err(|_| {
format!(
"外部生成任务 {} 的入队价格 price_mud_points={} 超出 worker 支持范围 0..={}",
job.job_id,
job.price_mud_points,
u32::MAX
)
})
}
fn external_generation_billing_claim_attempt(
job: &ExternalGenerationJobRecord,
) -> Result<u32, String> {
if job.attempt == 0 {
return Err(format!(
"外部生成任务 {} 缺少有效的 claim attempt",
job.job_id
));
}
Ok(job.attempt)
}
fn external_generation_worker_heartbeat_interval(lease: Duration) -> Duration {
let heartbeat_millis = (lease.as_millis() / 3).clamp(250, 30_000) as u64;
Duration::from_millis(heartbeat_millis)
}
fn external_generation_worker_deadlines(
started_at: Instant,
job_timeout: Duration,
) -> (Instant, Instant) {
let terminal_write_reserve =
EXTERNAL_GENERATION_WORKER_TERMINAL_WRITE_RESERVE.min(job_timeout / 2);
let Some(job_deadline) = started_at.checked_add(job_timeout) else {
return (started_at, started_at);
};
let provider_deadline = started_at
.checked_add(job_timeout.saturating_sub(terminal_write_reserve))
.unwrap_or(started_at);
(job_deadline, provider_deadline)
}
fn external_generation_worker_job_timeout(config: &AppConfig, job_kind: &str) -> Duration {
match job_kind {
EDITOR_IMAGE_GENERATION_JOB_KIND
| EDITOR_IMAGE_EDIT_JOB_KIND
| EDITOR_ICON_SPEC_GENERATION_JOB_KIND
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
| EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND
| EDITOR_VIDEO_GENERATION_JOB_KIND => config.external_generation_worker_long_job_timeout,
_ => config.external_generation_worker_job_timeout,
}
}
fn external_generation_worker_timeout_message(
job: &ExternalGenerationJobRecord,
timeout: Duration,
) -> String {
format!(
"外部生成任务 {}{})超过 worker 执行预算 {} 秒,已停止当前尝试",
job.job_id,
job.job_kind,
timeout.as_secs()
)
}
fn current_utc_micros() -> i64 {
offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any())]
#[test]
fn worker_write_guard_uses_claimed_job_lease_token() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let guard = build_external_generation_write_lease_guard("worker-a", &job)
.expect("guard should build");
assert_eq!(guard.job_id.as_deref(), Some("extgen-1"));
assert_eq!(guard.worker_id.as_deref(), Some("worker-a"));
assert_eq!(guard.lease_token.as_deref(), Some("lease-1"));
}
#[test]
fn editor_worker_caller_carries_phase_reporter() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let caller = editor_generation_worker_caller("worker-a", &job)
.expect("worker caller should include claimed job phase reporter");
assert!(caller.phase_reporter.is_some());
}
#[test]
fn editor_image_edit_worker_parses_versioned_snapshot_payload() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string();
job.request_payload_json = json!({
"version": 1,
"request": {
"prompt": "改成蓝色",
"sourceReferenceId": "resource-source"
},
"source": {
"referenceKind": "project-resource",
"sourceReferenceId": "resource-source",
"resourceId": "resource-source",
"assetId": null,
"assetObjectId": "object-source",
"bucket": "bucket",
"objectKey": "generated/source.png",
"sourceAssetKind": "character",
"effectiveAssetKind": "character",
"mediaType": "image",
"targetResourceId": null
}
})
.to_string();
let payload = parse_editor_image_edit_worker_payload(&job)
.expect("versioned image edit payload should parse");
assert_eq!(payload.request.source_reference_id, "resource-source");
assert_eq!(
payload.source.expect("snapshot should exist").object_key,
"generated/source.png"
);
}
#[test]
fn legacy_editor_image_edit_payload_migrates_only_business_ids() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string();
job.request_payload_json = json!({
"prompt": "改成蓝色",
"sourceImageSrc": "raw/generated/source.png",
"sourceResourceId": "resource-source",
"projectId": "project-1",
"assetKind": "editor_agent_edit_image",
"generationInputs": { "source": "editor-agent" }
})
.to_string();
let legacy_agent_payload = parse_editor_image_edit_worker_payload(&job)
.expect("历史 Agent 图片编辑 payload 应由 worker 解析");
assert_eq!(
legacy_agent_payload.request.source_reference_id,
"resource-source"
);
assert!(legacy_agent_payload.source.is_none());
job.request_payload_json = json!({
"prompt": "改成蓝色",
"sourceImageSrc": "raw/generated/source.png",
"assetKind": "character"
})
.to_string();
let raw_only_payload = parse_editor_image_edit_worker_payload(&job)
.expect("raw-only legacy payload should migrate without object-key lookup");
assert_eq!(
raw_only_payload.request.source_reference_id,
"raw/generated/source.png"
);
assert!(raw_only_payload.source.is_none());
}
#[cfg(any())]
#[test]
fn worker_write_guard_requires_claimed_job_lease_token() {
let job = external_generation_job_record_fixture(None);
let error = build_external_generation_write_lease_guard("worker-a", &job)
.expect_err("missing token should fail");
assert!(error.contains("缺少 lease token"));
}
#[test]
fn worker_billing_price_rejects_values_above_u32() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.price_mud_points = u64::from(u32::MAX);
assert_eq!(
external_generation_billing_price_mud_points(&job),
Ok(u32::MAX)
);
job.price_mud_points += 1;
let error = external_generation_billing_price_mud_points(&job)
.expect_err("out-of-range queued price should fail");
assert!(error.contains("extgen-1"));
assert!(error.contains("price_mud_points=4294967296"));
assert!(error.contains("4294967295"));
}
#[test]
fn worker_billing_requires_claim_attempt() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
assert_eq!(external_generation_billing_claim_attempt(&job), Ok(1));
job.attempt = 0;
let error = external_generation_billing_claim_attempt(&job)
.expect_err("unclaimed job should not enter billing context");
assert!(error.contains("extgen-1"));
assert!(error.contains("claim attempt"));
}
#[tokio::test]
async fn worker_response_error_message_prefers_api_error_details() {
let response = axum::http::Response::builder()
.status(axum::http::StatusCode::BAD_GATEWAY)
.body(axum::body::Body::from(
json!({
"ok": false,
"data": null,
"error": {
"code": "UPSTREAM_ERROR",
"message": "上游服务请求失败",
"details": {
"provider": "vector-engine",
"reason": "VECTOR_ENGINE_API_KEY 未配置",
"message": "提交编辑器音效任务失败:missing field sound"
}
},
"meta": {}
})
.to_string(),
))
.expect("response should build");
let message = response_error_message(response).await;
assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置");
}
#[tokio::test]
async fn worker_heartbeat_wait_does_not_stop_work_progress() {
let connection = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
let work_started = std::sync::Arc::new(tokio::sync::Notify::new());
let work_connection = connection.clone();
let work_started_signal = work_started.clone();
let work = async move {
let permit = work_connection
.acquire_owned()
.await
.expect("work should acquire the only connection");
work_started_signal.notify_one();
tokio::time::sleep(Duration::from_millis(25)).await;
drop(permit);
Ok(())
};
let heartbeat_connection = connection.clone();
let heartbeat = async move {
work_started.notified().await;
let _permit = heartbeat_connection
.acquire_owned()
.await
.expect("heartbeat should acquire after work releases the connection");
std::future::pending::<Result<(), String>>().await
};
let outcome = await_external_generation_job_execution(
work,
heartbeat,
Instant::now() + Duration::from_secs(1),
)
.await;
assert_eq!(
outcome,
ExternalGenerationJobExecutionOutcome::Finished(Ok(()))
);
}
#[tokio::test]
async fn worker_deadline_drops_local_work_future_before_returning() {
let connection = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
let work_connection = connection.clone();
let work = async move {
let _permit = work_connection
.acquire_owned()
.await
.expect("work should acquire the only connection");
std::future::pending::<Result<(), String>>().await
};
let heartbeat = std::future::pending::<Result<(), String>>();
let outcome = await_external_generation_job_execution(
work,
heartbeat,
Instant::now() + Duration::from_millis(25),
)
.await;
assert_eq!(outcome, ExternalGenerationJobExecutionOutcome::TimedOut);
assert!(
connection.try_acquire().is_ok(),
"deadline 返回前应先 drop 传入的 work future 并释放其持有的资源"
);
}
#[tokio::test]
async fn worker_detached_work_keeps_running_after_deadline() {
let finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let finished_flag = finished.clone();
let work_handle = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
finished_flag.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
});
let job = external_generation_job_record_fixture(Some("lease-1"));
detach_external_generation_work_until_lease_expiry(
work_handle,
&job,
Duration::from_millis(200),
"任务超过执行预算",
None,
);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
finished.load(std::sync::atomic::Ordering::SeqCst),
"超时后在途 work 应继续执行完成,而不是被取消"
);
}
#[tokio::test]
async fn worker_detached_work_keeps_execution_slot_until_finished() {
let slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
let permit = slots
.clone()
.acquire_owned()
.await
.expect("the only execution slot should be available");
let work_handle = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
});
let job = external_generation_job_record_fixture(Some("lease-1"));
detach_external_generation_work_until_lease_expiry(
work_handle,
&job,
Duration::from_millis(200),
"任务超过执行预算",
Some(permit),
);
assert!(
slots.try_acquire().is_err(),
"脱管 work 完成前不得重新领取执行容量"
);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
slots.try_acquire().is_ok(),
"脱管 work 完成后应归还执行容量"
);
}
#[tokio::test]
async fn worker_reaps_completed_tasks_while_queue_remains_busy() {
let mut tasks = JoinSet::new();
let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
const TASK_COUNT: usize = 128;
for _ in 0..TASK_COUNT {
let completed = completed.clone();
tasks.spawn(async move {
completed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
});
}
while completed.load(std::sync::atomic::Ordering::SeqCst) < TASK_COUNT {
tokio::task::yield_now().await;
}
assert_eq!(tasks.len(), TASK_COUNT);
reap_finished_external_generation_worker_tasks(&mut tasks);
assert!(tasks.is_empty(), "已完成任务的 JoinHandle 应在每轮被回收");
}
#[tokio::test]
async fn worker_detached_work_is_aborted_after_lease_arbitration_window() {
let connection = std::sync::Arc::new(tokio::sync::Semaphore::new(1));
let work_connection = connection.clone();
let work_handle = tokio::spawn(async move {
let _permit = work_connection
.acquire_owned()
.await
.expect("work should acquire the only connection");
std::future::pending::<Result<(), String>>().await
});
let job = external_generation_job_record_fixture(Some("lease-1"));
detach_external_generation_work_until_lease_expiry(
work_handle,
&job,
Duration::from_millis(10),
"任务超过执行预算",
None,
);
let reacquired =
tokio::time::timeout(Duration::from_secs(2), connection.acquire_owned()).await;
assert!(
reacquired.is_ok(),
"超过租约仲裁窗口后应取消仍未结束的 work 并释放其持有的资源"
);
}
#[test]
fn editor_generation_result_payload_keeps_only_lightweight_slice_warning() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"spritesheetImageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
"iconImageSrcs": [{"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST"}],
"sliceWarning": {
"code": "insufficient-connected-components",
"reason": "连通域数量不足"
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
assert_eq!(payload["sourceModule"], json!("editor"));
assert_eq!(payload["sourceEntityId"], json!("project-1"));
assert_eq!(
payload["warning"],
json!({
"code": "insufficient-connected-components",
"reason": "图集已生成,但自动拆分未完成:连通域数量不足"
})
);
assert!(payload.get("spritesheetImageSrc").is_none());
assert!(payload.get("iconImageSrcs").is_none());
assert!(payload.get("editor-agent-tool-call-result").is_none());
}
#[test]
fn editor_agent_result_payload_keeps_compact_response() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "editor-agent:conversation-1:7:generate-image".to_string();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
.to_string();
let response = json!({
"imageSrc": "/api/assets/object/generated.png",
"objectKey": "users/user-1/generated.png",
"assetObjectId": "asset-object-1",
"width": 1024,
"height": 1024,
"sourceType": "generated",
"prompt": "castle",
"actualPrompt": null,
"model": "gpt-image-2",
"provider": "VectorEngine",
"generationInputs": {
"fields": { "prompt": "castle" },
"characterAnimation": { "frameCount": 8 },
"screenColorHex": "#CFEFFF",
"mattingProvider": "BgFilter",
"mattingModel": "birefnet",
},
"taskId": "provider-task-1",
"resource": {
"resourceId": "resource-1",
"objectKey": "users/user-1/generated.png",
"assetObjectId": "asset-object-1",
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
},
"asset": { "assetId": "asset-1" },
"project": { "projectId": "project-1" },
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker result should be valid JSON");
assert_eq!(
payload["editor-agent-tool-call-result"]["imageSrc"],
json!("/api/assets/object/generated.png")
);
assert_eq!(
payload["editor-agent-tool-call-result"]["resource"],
json!({
"resourceId": "resource-1",
"objectKey": "users/user-1/generated.png",
"assetObjectId": "asset-object-1",
})
);
assert!(
payload["editor-agent-tool-call-result"]
.get("asset")
.is_none()
);
assert!(
payload["editor-agent-tool-call-result"]
.get("project")
.is_none()
);
assert_eq!(
payload["editor-agent-tool-call-result"]["model"],
json!("gpt-image-2")
);
assert!(
payload["editor-agent-tool-call-result"]
.get("provider")
.is_none()
);
assert_eq!(
payload["editor-agent-tool-call-result"]["generationInputs"],
json!({
"fields": { "prompt": "castle" },
"characterAnimation": { "frameCount": 8 },
})
);
assert!(!payload.to_string().contains("data:image"));
}
#[test]
fn compact_editor_generation_result_removes_internal_models_case_insensitively() {
for model in [
"anime-seg",
" BgFilter Complex ",
"BIREFNET",
"connected-components",
"screen-color-keying",
"segment-common-image",
] {
let compact = compact_editor_generation_result(json!({
"model": model,
"provider": "internal-provider",
}));
assert!(compact.get("model").is_none(), "model={model}");
assert!(compact.get("provider").is_none(), "model={model}");
}
}
#[test]
fn editor_agent_audio_compact_results_remain_reconcileable() {
use crate::editor_agent::reconcile_completed_editor_agent_tool_call_for_test;
use platform_editor_agent::{
agent::tools::{
generate_background_music::GenerateBackgroundMusicTool,
generate_sound_effect::GenerateSoundEffectTool,
},
framework::tool::Tool,
};
use shared_contracts::editor_agent::{EditorAgentMessage, EditorAgentToolCallStatus};
for (tool_name, prompt, actual_prompt, audio_kind, model, provider) in [
(
GenerateSoundEffectTool::NAME,
"按钮点击声",
"A short button click",
"sound-effect",
"eleven_text_to_sound_v2",
"elevenlabs",
),
(
GenerateBackgroundMusicTool::NAME,
"森林背景音乐",
"森林背景音乐",
"background-music",
"chirp-v5",
"vectorengine",
),
] {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = format!("editor-agent:conversation-1:7:{tool_name}");
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
.to_string();
let response = json!({
"ok": true,
"audioSrc": "/generated/audio.mp3",
"width": 420,
"height": 120,
"sourceType": "generated",
"prompt": prompt,
"actualPrompt": actual_prompt,
"model": model,
"provider": provider,
"taskId": "task-1",
"priceMudPoints": 5,
"audioKind": audio_kind,
"durationSeconds": if audio_kind == "sound-effect" { json!(5.25) } else { Value::Null },
"loop": if audio_kind == "sound-effect" { json!(false) } else { Value::Null },
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker compact payload should serialize");
assert!(payload.get("editor-agent-tool-call-result").is_some());
assert!(
payload["editor-agent-tool-call-result"]
.get("provider")
.is_none()
);
let mut message: EditorAgentMessage = serde_json::from_value(json!({
"id": 1,
"role": "system",
"text": "waiting",
"attachments": [],
"toolCall": {
"toolName": tool_name,
"status": "not_completed",
"args": { "prompt": prompt },
"displayArgs": {
"stringArgs": [],
"imageArgs": [],
"extras": { "priceMudPoints": 5 }
},
"externalJobId": "job-1",
"images": [],
"audios": []
},
"createdAt": "2026-08-06T00:00:00Z"
}))
.expect("pending audio Agent message should deserialize");
let payload_json = payload.to_string();
reconcile_completed_editor_agent_tool_call_for_test(
&mut message,
Some(payload_json.as_str()),
)
.expect("worker compact audio result should reconcile");
let tool_call = message.tool_call.expect("tool call should remain present");
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
assert_eq!(tool_call.audios[0].audio_src, "/generated/audio.mp3");
}
}
#[test]
fn editor_agent_spritesheet_result_keeps_all_persisted_slices() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "editor-agent:conversation-1:7:generate-icon-spritesheet".to_string();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
.to_string();
let response = json!({
"spritesheetImageSrc": "/api/assets/object/sheet.png",
"spritesheetWidth": 512,
"spritesheetHeight": 512,
"taskId": "provider-task-1",
"spritesheetResource": {
"resourceId": "sheet-resource",
"objectKey": "users/user-1/sheet.png",
"assetObjectId": "sheet-object",
},
"iconImageSrcs": [
{
"name": "backpack",
"imageSrc": "/api/assets/object/backpack.png",
"width": 64,
"height": 64,
"resource": {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
},
"asset": { "assetId": "icon-asset-1" },
},
{
"name": "map",
"imageSrc": "/api/assets/object/map.png",
"width": 64,
"height": 64,
"resource": {
"resourceId": "icon-resource-2",
"objectKey": "users/user-1/map.png",
"assetObjectId": "icon-object-2",
"sourceResourceId": "sheet-resource",
},
},
],
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker result should be valid JSON");
assert_eq!(
payload["editor-agent-tool-call-result"]["iconImageSrcs"]
.as_array()
.map(Vec::len),
Some(2)
);
assert_eq!(
payload["editor-agent-tool-call-result"]["iconImageSrcs"][0]["resource"],
json!({
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
})
);
assert!(
payload["editor-agent-tool-call-result"]["iconImageSrcs"][0]
.get("asset")
.is_none()
);
assert!(!payload.to_string().contains("data:image"));
}
#[test]
fn editor_generation_result_payload_merges_common_and_slice_warnings() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"data": {
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
"warning": {
"code": "unsupported-image-style",
"reason": "不支持的图片风格,已按无风格继续生成。"
},
"sliceWarning": {
"code": "insufficient-connected-components",
"reason": "有效连通域不足"
}
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
// 中文注释:风格归一化告警与拆分告警可以并存,队列只有一个 warning 字段,
// 必须拼接后收敛 code,不能让其中任何一条消失。
assert_eq!(
payload["warning"],
json!({
"code": "multiple-generation-warnings",
"reason": "不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:有效连通域不足"
})
);
assert!(payload.get("imageSrc").is_none());
}
#[test]
fn external_generation_result_payload_keeps_fixed_spritesheet_layout() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "external-api-generation:conversation-1:7:icon-spritesheet".to_string();
let response = json!({
"sliceLayout": "grid-2x2",
"iconImageSrcs": [
{ "name": "素材 1", "imageSrc": "/api/assets/object/one.png" },
{ "name": "素材 2", "imageSrc": "/api/assets/object/two.png" },
{ "name": "素材 3", "imageSrc": "/api/assets/object/three.png" },
{ "name": "素材 4", "imageSrc": "/api/assets/object/four.png" }
]
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker result should be valid JSON");
assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2"));
assert_eq!(
payload["result"]["iconImageSrcs"].as_array().map(Vec::len),
Some(4)
);
}
#[test]
fn editor_generation_result_payload_keeps_single_warning_untouched() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"data": {
"warning": {
"code": "postprocess-failed-source-preserved",
"reason": "生成任务成功,后处理失败。"
}
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
// 中文注释:透明背景最终失败不会进入拆分,此时仍是单条告警,原样保留。
assert_eq!(
payload["warning"],
json!({
"code": "postprocess-failed-source-preserved",
"reason": "生成任务成功,后处理失败。"
})
);
}
#[test]
fn editor_image_job_completion_is_committed_by_atomic_persistence() {
let source = include_str!("external_generation_worker.rs");
let start = source
.find("EDITOR_IMAGE_GENERATION_JOB_KIND => {")
.expect("editor image worker branch should exist");
let branch_tail = &source[start..];
let end = branch_tail
.find("EDITOR_IMAGE_EDIT_JOB_KIND => {")
.expect("editor image worker branch end marker should exist");
let branch = &branch_tail[..end];
assert!(branch.contains("Ok(_) => Ok(())"));
assert!(
!branch.contains("complete_editor_generation_job"),
"editor image completion must be part of the atomic result persistence"
);
}
#[test]
fn icon_spec_job_completion_is_committed_by_atomic_persistence() {
let source = include_str!("external_generation_worker.rs");
let start = source
.find("EDITOR_ICON_SPEC_GENERATION_JOB_KIND => {")
.expect("icon spec worker branch should exist");
let branch_tail = &source[start..];
let end = branch_tail
.find("EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND => {")
.expect("icon spec worker branch end marker should exist");
let branch = &branch_tail[..end];
assert!(branch.contains("Ok(_) => Ok(())"));
assert!(
!branch.contains("complete_editor_generation_job"),
"icon spec completion must be part of the atomic result persistence"
);
}
#[test]
fn editor_generation_result_payload_accepts_envelope_and_redacts_inline_media() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let response = json!({
"data": {
"sliceWarning": {
"code": "slice-persistence-failed",
"reason": "provider returned data:image/png;base64,AAAA"
}
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
assert_eq!(
payload["warning"]["reason"],
json!(EDITOR_GENERATION_WARNING_REDACTED_MESSAGE)
);
assert!(!payload.to_string().contains("data:image"));
}
#[test]
fn external_api_result_keeps_stable_artifacts_and_removes_unstable_media() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "external-api-generation:editor_image_generation:fingerprint".to_string();
let response = json!({
"data": {
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
"videoSrc": "blob:https://example.test/video",
"audioSrc": "https://cdn.example.test/audio.mp3?X-Amz-Signature=secret",
"previewVideoPath": "https://cdn.example.test/stable-looking-but-external.mp4",
"thumbnailSrc": "/api/assets/object/thumbnail.png?expires=1&signature=secret",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"width": 1024,
"height": 1024,
"durationSeconds": 7.42,
"loop": true,
"provider": "internal-provider-must-not-persist",
"resource": {
"resourceId": "resource-main",
"projectId": "project-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"sourceResourceId": "source-resource-main",
"imageSrc": "https://cdn.example.test/main.png?signature=secret",
"width": 1024,
"height": 1024,
"prompt": "不应复制完整资源元数据"
},
"asset": {
"assetId": "asset-main",
"folderId": "folder-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"imageSrc": "/api/assets/object/main.png",
"thumbnailSrc": "https://cdn.example.test/thumb.png?signature=secret",
"width": 1024,
"height": 1024,
"generationInputs": {"private": true}
},
"frames": [
{
"imageSrc": "/generated/action/frame-01.png",
"objectKey": "generated/action/frame-01.png",
"assetObjectId": "asset-object-frame-01",
"width": 192,
"height": 256,
"provider": "internal-provider-must-not-persist"
},
{
"imageSrc": "/generated/action/frame-02.png",
"objectKey": "generated/action/frame-02.png",
"assetObjectId": "asset-object-frame-02",
"width": 192,
"height": 256
}
],
"project": {
"projectId": "project-1",
"canvas": {"layers": ["large-layout-must-not-persist"]}
},
"warning": {
"code": "dimension-restore-fallback",
"reason": "已保留 provider 实际输出尺寸。"
}
},
"meta": {
"requestId": "worker-envelope-must-not-persist"
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("外部生成结果应是合法 JSON");
let result = &payload["result"];
assert!(result.get("project").is_none());
assert!(result.get("provider").is_none());
for unstable_field in [
"imageSrc",
"videoSrc",
"audioSrc",
"previewVideoPath",
"thumbnailSrc",
] {
assert!(
result.get(unstable_field).is_none(),
"不稳定媒体字段 {unstable_field} 不得持久化"
);
}
assert_eq!(
result["objectKey"],
json!("users/user-1/generated/main.png")
);
assert_eq!(result["assetObjectId"], json!("asset-object-main"));
assert_eq!(result["durationSeconds"], json!(7.42));
assert_eq!(result["loop"], json!(true));
assert_eq!(result["resource"]["resourceId"], json!("resource-main"));
assert_eq!(
result["resource"]["sourceResourceId"],
json!("source-resource-main")
);
assert_eq!(
result["resource"]["objectKey"],
json!("users/user-1/generated/main.png")
);
assert!(result["resource"].get("imageSrc").is_none());
assert!(result["resource"].get("prompt").is_none());
assert_eq!(result["asset"]["assetId"], json!("asset-main"));
assert_eq!(
result["asset"]["imageSrc"],
json!("/api/assets/object/main.png")
);
assert!(result["asset"].get("thumbnailSrc").is_none());
assert!(result["asset"].get("generationInputs").is_none());
assert_eq!(
result["frames"][0],
json!({
"imageSrc": "/generated/action/frame-01.png",
"objectKey": "generated/action/frame-01.png",
"assetObjectId": "asset-object-frame-01",
"width": 192,
"height": 256
})
);
assert_eq!(
result["warning"],
json!({
"code": "dimension-restore-fallback",
"reason": "已保留 provider 实际输出尺寸。"
})
);
assert_eq!(payload["warning"], result["warning"]);
assert!(result.get("prompt").is_none());
assert!(result.get("actualPrompt").is_none());
let serialized = payload.to_string().to_ascii_lowercase();
for forbidden in [
"data:",
"blob:",
"x-amz-signature",
"?signature=",
"large-layout",
] {
assert!(
!serialized.contains(forbidden),
"compact result 不应包含 {forbidden}"
);
}
}
#[test]
fn game_creator_client_result_keeps_completed_grid_spritesheet_for_recovery() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = format!(
"{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:editor_icon_spritesheet_generation:fingerprint"
);
job.job_kind = EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND.to_string();
let response = json!({
"data": {
"spritesheetImageSrc": "/api/assets/object/core-sheet.png",
"spritesheetWidth": 1024,
"spritesheetHeight": 1024,
"sliceLayout": "grid-2x2",
"spritesheetResource": {
"resourceId": "sheet-resource-1",
"objectKey": "users/user-1/core-sheet.png",
"imageSrc": "/api/assets/object/core-sheet.png"
},
"spritesheetAsset": {
"assetId": "sheet-asset-1",
"objectKey": "users/user-1/core-sheet.png",
"imageSrc": "/api/assets/object/core-sheet.png"
},
"iconImageSrcs": [
{"name": "玩家", "objectKey": "users/user-1/player.png", "imageSrc": "/api/assets/object/player.png"},
{"name": "目标", "objectKey": "users/user-1/targets.png", "imageSrc": "/api/assets/object/targets.png"},
{"name": "场景", "objectKey": "users/user-1/scene.png", "imageSrc": "/api/assets/object/scene.png"},
{"name": "反馈", "objectKey": "users/user-1/feedback.png", "imageSrc": "/api/assets/object/feedback.png"}
]
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("游戏创作客户端完成结果应持久化为合法 JSON");
assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2"));
assert_eq!(
payload["result"]["iconImageSrcs"].as_array().map(Vec::len),
Some(4)
);
assert_eq!(
payload["result"]["spritesheetResource"]["resourceId"],
json!("sheet-resource-1")
);
assert_eq!(
payload["result"]["spritesheetAsset"]["assetId"],
json!("sheet-asset-1")
);
assert_eq!(
payload["result"]["iconImageSrcs"][0]["objectKey"],
json!("users/user-1/player.png")
);
assert!(!payload.to_string().contains("prompt"));
}
#[test]
fn non_external_job_does_not_publish_query_result() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let payload: Value = serde_json::from_str(&editor_generation_result_payload_json(
&job,
&json!({
"objectKey": "users/user-1/generated/main.png",
"resource": {"resourceId": "resource-main"}
}),
))
.expect("普通编辑器任务结果应为合法 JSON");
assert!(payload.get("result").is_none());
}
#[test]
fn worker_job_timeout_uses_long_budget_for_image_and_video_jobs() {
let config = AppConfig {
external_generation_worker_job_timeout: Duration::from_secs(60),
external_generation_worker_long_job_timeout: Duration::from_secs(600),
..AppConfig::default()
};
assert_eq!(
external_generation_worker_job_timeout(&config, EDITOR_IMAGE_GENERATION_JOB_KIND),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(&config, EDITOR_IMAGE_EDIT_JOB_KIND),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(&config, EDITOR_ICON_SPEC_GENERATION_JOB_KIND),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(
&config,
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(
&config,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(
&config,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND
),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(&config, EDITOR_VIDEO_GENERATION_JOB_KIND),
Duration::from_secs(600)
);
assert_eq!(
external_generation_worker_job_timeout(&config, EDITOR_BACKGROUND_REMOVAL_JOB_KIND),
Duration::from_secs(60)
);
assert_eq!(
external_generation_worker_job_timeout(
&config,
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND
),
Duration::from_secs(60)
);
}
#[test]
fn worker_provider_deadline_reserves_terminal_write_budget() {
let started_at = Instant::now();
let (job_deadline, provider_deadline) =
external_generation_worker_deadlines(started_at, Duration::from_secs(1_800));
assert_eq!(
job_deadline.duration_since(started_at),
Duration::from_secs(1_800)
);
assert_eq!(
provider_deadline.duration_since(started_at),
Duration::from_secs(1_740)
);
assert_eq!(
job_deadline.duration_since(provider_deadline),
Duration::from_secs(60)
);
let (short_job_deadline, short_provider_deadline) =
external_generation_worker_deadlines(started_at, Duration::from_secs(30));
assert_eq!(
short_job_deadline.duration_since(started_at),
Duration::from_secs(30)
);
assert_eq!(
short_provider_deadline.duration_since(started_at),
Duration::from_secs(15)
);
}
#[test]
fn worker_request_context_carries_provider_deadline() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let provider_deadline = Instant::now() + Duration::from_secs(30);
let request_context = worker_request_context(&job, provider_deadline);
assert_eq!(
request_context.external_call_deadline(),
Some(provider_deadline)
);
}
#[test]
fn worker_timeout_message_mentions_job_and_budget() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let message = external_generation_worker_timeout_message(&job, Duration::from_secs(90));
assert!(message.contains("extgen-1"));
assert!(message.contains(EDITOR_IMAGE_GENERATION_JOB_KIND));
assert!(message.contains("90 秒"));
}
#[test]
fn terminal_worker_failure_delegates_refund_to_the_fenced_failure_transaction() {
let source = include_str!("external_generation_worker.rs");
let body = source
.split_once("async fn fail_job(")
.and_then(|(_, tail)| {
tail.split_once("async fn renew_job_lease(")
.map(|(body, _)| body)
})
.expect("fail_job helper");
assert!(!body.contains("settle_current_external_generation_attempt_refund("));
assert!(body.contains(".fail_external_generation_job("));
assert!(body.contains("refund_ledger_id: None"));
}
fn external_generation_job_record_fixture(
lease_token: Option<&str>,
) -> ExternalGenerationJobRecord {
ExternalGenerationJobRecord {
job_id: "extgen-1".to_string(),
dedupe_key: "editor:image-generation:project-1:extgen-1".to_string(),
job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(),
owner_user_id: "user-1".to_string(),
source_module: "editor".to_string(),
source_entity_id: "project-1".to_string(),
request_label: "画布图片生成".to_string(),
request_payload_json: "{}".to_string(),
status: "running".to_string(),
attempt: 1,
max_attempts: 1,
last_error_message: None,
worker_id: Some("worker-a".to_string()),
lease_expires_at: Some("2026-06-03T00:00:00Z".to_string()),
available_at: "2026-06-03T00:00:00Z".to_string(),
result_payload_json: None,
created_at: "2026-06-03T00:00:00Z".to_string(),
started_at: Some("2026-06-03T00:00:00Z".to_string()),
completed_at: None,
updated_at: "2026-06-03T00:00:00Z".to_string(),
updated_at_micros: 1_780_444_800_000_000,
lease_token: lease_token.map(ToOwned::to_owned),
price_mud_points: 2,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: Some("generating".to_string()),
}
}
}