Merge remote-tracking branch 'origin/master' into codex/editor-asset-library
# Conflicts: # server-rs/crates/spacetime-client/src/lib.rs # server-rs/crates/spacetime-client/src/mapper.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
936
server-rs/crates/spacetime-module/src/external_generation.rs
Normal file
936
server-rs/crates/spacetime-module/src/external_generation.rs
Normal file
@@ -0,0 +1,936 @@
|
||||
use crate::*;
|
||||
|
||||
const EXTERNAL_GENERATION_STATUS_PENDING: &str = "pending";
|
||||
const EXTERNAL_GENERATION_STATUS_RUNNING: &str = "running";
|
||||
const EXTERNAL_GENERATION_STATUS_COMPLETED: &str = "completed";
|
||||
const EXTERNAL_GENERATION_STATUS_FAILED: &str = "failed";
|
||||
const EXTERNAL_GENERATION_STATUS_CANCELLED: &str = "cancelled";
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = external_generation_job,
|
||||
index(
|
||||
accessor = by_external_generation_job_status_available,
|
||||
btree(columns = [status, available_at])
|
||||
),
|
||||
index(
|
||||
accessor = by_external_generation_job_worker_id,
|
||||
btree(columns = [worker_id])
|
||||
),
|
||||
index(
|
||||
accessor = by_external_generation_job_source,
|
||||
btree(columns = [source_module, source_entity_id])
|
||||
),
|
||||
index(
|
||||
accessor = by_external_generation_job_owner_user_id,
|
||||
btree(columns = [owner_user_id])
|
||||
)
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalGenerationJob {
|
||||
#[primary_key]
|
||||
pub(crate) job_id: String,
|
||||
#[unique]
|
||||
pub(crate) dedupe_key: String,
|
||||
pub(crate) job_kind: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) source_module: String,
|
||||
pub(crate) source_entity_id: String,
|
||||
pub(crate) request_label: String,
|
||||
pub(crate) request_payload_json: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) attempt: u32,
|
||||
pub(crate) max_attempts: u32,
|
||||
pub(crate) last_error_message: Option<String>,
|
||||
pub(crate) worker_id: Option<String>,
|
||||
pub(crate) lease_expires_at: Option<Timestamp>,
|
||||
pub(crate) available_at: Timestamp,
|
||||
pub(crate) result_payload_json: Option<String>,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) started_at: Option<Timestamp>,
|
||||
pub(crate) completed_at: Option<Timestamp>,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobEnqueueInput {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub max_attempts: u32,
|
||||
pub available_at_micros: i64,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobClaimInput {
|
||||
pub worker_id: String,
|
||||
pub limit: u32,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub claimed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobRenewLeaseInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub lease_expires_at_micros: i64,
|
||||
pub renewed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobCompleteInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub completed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobFailInput {
|
||||
pub job_id: String,
|
||||
pub worker_id: String,
|
||||
pub lease_token: String,
|
||||
pub error_message: String,
|
||||
pub retry_after_micros: i64,
|
||||
pub failed_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobGetInput {
|
||||
pub job_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobSnapshot {
|
||||
pub job_id: String,
|
||||
pub dedupe_key: String,
|
||||
pub job_kind: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_payload_json: String,
|
||||
pub status: String,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub last_error_message: Option<String>,
|
||||
pub worker_id: Option<String>,
|
||||
pub lease_expires_at_micros: Option<i64>,
|
||||
pub available_at_micros: i64,
|
||||
pub result_payload_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub started_at_micros: Option<i64>,
|
||||
pub completed_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
pub lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationJobProcedureResult {
|
||||
pub ok: bool,
|
||||
pub job: Option<ExternalGenerationJobSnapshot>,
|
||||
pub jobs: Vec<ExternalGenerationJobSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationQueueStatsSnapshot {
|
||||
pub pending_count: u32,
|
||||
pub delayed_pending_count: u32,
|
||||
pub claimable_pending_count: u32,
|
||||
pub running_active_count: u32,
|
||||
pub expired_running_count: u32,
|
||||
// 中文注释:保留字段兼容已生成 bindings;controller 只按非终态队列压力扩缩容,不每轮扫描历史终态任务。
|
||||
pub terminal_count: u32,
|
||||
pub claimable_count: u32,
|
||||
pub oldest_claimable_age_micros: Option<i64>,
|
||||
pub now_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ExternalGenerationQueueStatsProcedureResult {
|
||||
pub ok: bool,
|
||||
pub stats: Option<ExternalGenerationQueueStatsSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn enqueue_external_generation_job_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobEnqueueInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| enqueue_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn claim_external_generation_jobs_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobClaimInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| claim_external_generation_jobs_tx(tx, input.clone())) {
|
||||
Ok(jobs) => ExternalGenerationJobProcedureResult {
|
||||
ok: true,
|
||||
job: None,
|
||||
jobs,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn complete_external_generation_job_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| complete_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn renew_external_generation_job_lease_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| renew_external_generation_job_lease_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn fail_external_generation_job_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobFailInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| fail_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_external_generation_job_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: ExternalGenerationJobGetInput,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_external_generation_job_tx(tx, input.clone())) {
|
||||
Ok(job) => single_external_generation_job_result(job),
|
||||
Err(message) => failed_external_generation_job_result(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_external_generation_queue_stats_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
) -> ExternalGenerationQueueStatsProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_external_generation_queue_stats_tx(tx)) {
|
||||
Ok(stats) => ExternalGenerationQueueStatsProcedureResult {
|
||||
ok: true,
|
||||
stats: Some(stats),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => ExternalGenerationQueueStatsProcedureResult {
|
||||
ok: false,
|
||||
stats: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_external_generation_job_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobEnqueueInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
validate_required("external_generation_job.job_id", &input.job_id)?;
|
||||
validate_required("external_generation_job.dedupe_key", &input.dedupe_key)?;
|
||||
validate_required("external_generation_job.job_kind", &input.job_kind)?;
|
||||
validate_required(
|
||||
"external_generation_job.owner_user_id",
|
||||
&input.owner_user_id,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.source_module",
|
||||
&input.source_module,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.source_entity_id",
|
||||
&input.source_entity_id,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.request_label",
|
||||
&input.request_label,
|
||||
)?;
|
||||
validate_required(
|
||||
"external_generation_job.request_payload_json",
|
||||
&input.request_payload_json,
|
||||
)?;
|
||||
|
||||
if let Some(row) = ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.dedupe_key()
|
||||
.find(&input.dedupe_key)
|
||||
{
|
||||
return Ok(map_external_generation_job_row(row));
|
||||
}
|
||||
if ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.find(&input.job_id)
|
||||
.is_some()
|
||||
{
|
||||
return Err("external_generation_job.job_id 已存在".to_string());
|
||||
}
|
||||
|
||||
let now = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
|
||||
let available_at = Timestamp::from_micros_since_unix_epoch(input.available_at_micros);
|
||||
let row = ExternalGenerationJob {
|
||||
job_id: input.job_id.trim().to_string(),
|
||||
dedupe_key: input.dedupe_key.trim().to_string(),
|
||||
job_kind: input.job_kind.trim().to_string(),
|
||||
owner_user_id: input.owner_user_id.trim().to_string(),
|
||||
source_module: input.source_module.trim().to_string(),
|
||||
source_entity_id: input.source_entity_id.trim().to_string(),
|
||||
request_label: input.request_label.trim().to_string(),
|
||||
request_payload_json: input.request_payload_json.trim().to_string(),
|
||||
status: EXTERNAL_GENERATION_STATUS_PENDING.to_string(),
|
||||
attempt: 0,
|
||||
max_attempts: input.max_attempts.max(1),
|
||||
last_error_message: None,
|
||||
worker_id: None,
|
||||
lease_expires_at: None,
|
||||
available_at,
|
||||
result_payload_json: None,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
updated_at: now,
|
||||
lease_token: None,
|
||||
};
|
||||
ctx.db.external_generation_job().insert(row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn claim_external_generation_jobs_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobClaimInput,
|
||||
) -> Result<Vec<ExternalGenerationJobSnapshot>, String> {
|
||||
validate_required("external_generation_job.worker_id", &input.worker_id)?;
|
||||
if input.limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let claim_time = ctx.timestamp;
|
||||
let lease_duration_micros = duration_between_micros(
|
||||
input.lease_expires_at_micros,
|
||||
input.claimed_at_micros,
|
||||
"external_generation_job.lease_duration",
|
||||
)?;
|
||||
let lease_expires_at = timestamp_after_micros(claim_time, lease_duration_micros);
|
||||
let worker_id = input.worker_id.trim().to_string();
|
||||
let limit = input.limit.min(64) as usize;
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
candidates.extend(
|
||||
ctx.db
|
||||
.external_generation_job()
|
||||
.by_external_generation_job_status_available()
|
||||
.filter(&EXTERNAL_GENERATION_STATUS_PENDING.to_string())
|
||||
.filter(|row| is_external_generation_job_claimable(row, claim_time)),
|
||||
);
|
||||
candidates.extend(
|
||||
ctx.db
|
||||
.external_generation_job()
|
||||
.by_external_generation_job_status_available()
|
||||
.filter(&EXTERNAL_GENERATION_STATUS_RUNNING.to_string())
|
||||
.filter(|row| is_external_generation_job_claimable(row, claim_time)),
|
||||
);
|
||||
|
||||
candidates.sort_by(|left, right| {
|
||||
left.available_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.cmp(&right.available_at.to_micros_since_unix_epoch())
|
||||
.then_with(|| {
|
||||
left.created_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.cmp(&right.created_at.to_micros_since_unix_epoch())
|
||||
})
|
||||
.then_with(|| left.job_id.cmp(&right.job_id))
|
||||
});
|
||||
|
||||
let mut claimed = Vec::new();
|
||||
for mut row in candidates.into_iter().take(limit) {
|
||||
let next_attempt = row.attempt.saturating_add(1);
|
||||
let lease_token = build_external_generation_lease_token(
|
||||
&row.job_id,
|
||||
&worker_id,
|
||||
next_attempt,
|
||||
claim_time,
|
||||
);
|
||||
row.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string();
|
||||
row.worker_id = Some(worker_id.clone());
|
||||
row.lease_expires_at = Some(lease_expires_at);
|
||||
row.lease_token = Some(lease_token);
|
||||
row.attempt = next_attempt;
|
||||
if row.started_at.is_none() {
|
||||
row.started_at = Some(claim_time);
|
||||
}
|
||||
row.updated_at = claim_time;
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
claimed.push(map_external_generation_job_row(row));
|
||||
}
|
||||
|
||||
Ok(claimed)
|
||||
}
|
||||
|
||||
fn complete_external_generation_job_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobCompleteInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
let mut row = get_worker_owned_external_generation_job(
|
||||
ctx,
|
||||
&input.job_id,
|
||||
&input.worker_id,
|
||||
&input.lease_token,
|
||||
)?;
|
||||
let completed_at = ctx.timestamp;
|
||||
row.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string();
|
||||
row.result_payload_json = input
|
||||
.result_payload_json
|
||||
.and_then(|value| normalize_optional_text(value.as_str()));
|
||||
row.lease_expires_at = None;
|
||||
row.completed_at = Some(completed_at);
|
||||
row.updated_at = completed_at;
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn get_external_generation_job_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobGetInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
validate_required("external_generation_job.job_id", &input.job_id)?;
|
||||
validate_required(
|
||||
"external_generation_job.owner_user_id",
|
||||
&input.owner_user_id,
|
||||
)?;
|
||||
let row = ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.find(&input.job_id.trim().to_string())
|
||||
.ok_or_else(|| "external_generation_job 不存在".to_string())?;
|
||||
if row.owner_user_id.trim() != input.owner_user_id.trim() {
|
||||
return Err("external_generation_job 不存在".to_string());
|
||||
}
|
||||
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn renew_external_generation_job_lease_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobRenewLeaseInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
let mut row = get_worker_owned_external_generation_job(
|
||||
ctx,
|
||||
&input.job_id,
|
||||
&input.worker_id,
|
||||
&input.lease_token,
|
||||
)?;
|
||||
let renewed_at = ctx.timestamp;
|
||||
let lease_duration_micros = duration_between_micros(
|
||||
input.lease_expires_at_micros,
|
||||
input.renewed_at_micros,
|
||||
"external_generation_job.lease_duration",
|
||||
)?;
|
||||
row.lease_expires_at = Some(timestamp_after_micros(renewed_at, lease_duration_micros));
|
||||
row.updated_at = renewed_at;
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn fail_external_generation_job_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: ExternalGenerationJobFailInput,
|
||||
) -> Result<ExternalGenerationJobSnapshot, String> {
|
||||
let error_message = input.error_message.trim();
|
||||
if error_message.is_empty() {
|
||||
return Err("external_generation_job.error_message 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut row = get_worker_owned_external_generation_job(
|
||||
ctx,
|
||||
&input.job_id,
|
||||
&input.worker_id,
|
||||
&input.lease_token,
|
||||
)?;
|
||||
let failed_at = ctx.timestamp;
|
||||
let retry_delay_micros = duration_between_micros(
|
||||
input.retry_after_micros,
|
||||
input.failed_at_micros,
|
||||
"external_generation_job.retry_delay",
|
||||
)?;
|
||||
row.last_error_message = Some(error_message.to_string());
|
||||
row.lease_expires_at = None;
|
||||
row.worker_id = None;
|
||||
row.lease_token = None;
|
||||
row.updated_at = failed_at;
|
||||
|
||||
if row.attempt < row.max_attempts {
|
||||
row.status = EXTERNAL_GENERATION_STATUS_PENDING.to_string();
|
||||
row.available_at = timestamp_after_micros(failed_at, retry_delay_micros);
|
||||
} else {
|
||||
row.status = EXTERNAL_GENERATION_STATUS_FAILED.to_string();
|
||||
row.completed_at = Some(failed_at);
|
||||
}
|
||||
|
||||
persist_external_generation_job_row(ctx, row.clone());
|
||||
Ok(map_external_generation_job_row(row))
|
||||
}
|
||||
|
||||
fn get_external_generation_queue_stats_tx(
|
||||
ctx: &ReducerContext,
|
||||
) -> Result<ExternalGenerationQueueStatsSnapshot, String> {
|
||||
let now = ctx.timestamp;
|
||||
let now_micros = now.to_micros_since_unix_epoch();
|
||||
let mut stats = ExternalGenerationQueueStatsSnapshot {
|
||||
pending_count: 0,
|
||||
delayed_pending_count: 0,
|
||||
claimable_pending_count: 0,
|
||||
running_active_count: 0,
|
||||
expired_running_count: 0,
|
||||
terminal_count: 0,
|
||||
claimable_count: 0,
|
||||
oldest_claimable_age_micros: None,
|
||||
now_micros,
|
||||
};
|
||||
|
||||
for row in ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.by_external_generation_job_status_available()
|
||||
.filter(&EXTERNAL_GENERATION_STATUS_PENDING.to_string())
|
||||
{
|
||||
stats.pending_count = stats.pending_count.saturating_add(1);
|
||||
if is_external_generation_job_claimable(&row, now) {
|
||||
stats.claimable_pending_count = stats.claimable_pending_count.saturating_add(1);
|
||||
record_external_generation_claimable_age(&mut stats, &row, now_micros);
|
||||
} else {
|
||||
stats.delayed_pending_count = stats.delayed_pending_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
for row in ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.by_external_generation_job_status_available()
|
||||
.filter(&EXTERNAL_GENERATION_STATUS_RUNNING.to_string())
|
||||
{
|
||||
if is_external_generation_job_claimable(&row, now) {
|
||||
stats.expired_running_count = stats.expired_running_count.saturating_add(1);
|
||||
record_external_generation_claimable_age(&mut stats, &row, now_micros);
|
||||
} else {
|
||||
stats.running_active_count = stats.running_active_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
stats.claimable_count = stats
|
||||
.claimable_pending_count
|
||||
.saturating_add(stats.expired_running_count);
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_external_generation_job_lease_for_tx(
|
||||
ctx: &ReducerContext,
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
lease_token: &str,
|
||||
expected_job_kinds: &[&str],
|
||||
expected_owner_user_id: &str,
|
||||
expected_source_module: &str,
|
||||
expected_source_entity_ids: &[String],
|
||||
) -> Result<(), String> {
|
||||
let row = get_worker_owned_external_generation_job(ctx, job_id, worker_id, lease_token)?;
|
||||
if !expected_job_kinds.is_empty()
|
||||
&& !expected_job_kinds
|
||||
.iter()
|
||||
.any(|expected| row.job_kind.trim() == expected.trim())
|
||||
{
|
||||
return Err("external_generation_job job_kind 与业务写回不匹配".to_string());
|
||||
}
|
||||
if row.owner_user_id.trim() != expected_owner_user_id.trim() {
|
||||
return Err("external_generation_job owner_user_id 与业务写回不匹配".to_string());
|
||||
}
|
||||
if row.source_module.trim() != expected_source_module.trim() {
|
||||
return Err("external_generation_job source_module 与业务写回不匹配".to_string());
|
||||
}
|
||||
if !expected_source_entity_ids
|
||||
.iter()
|
||||
.any(|expected| row.source_entity_id.trim() == expected.trim())
|
||||
{
|
||||
return Err("external_generation_job source_entity_id 与业务写回不匹配".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_worker_owned_external_generation_job(
|
||||
ctx: &ReducerContext,
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
lease_token: &str,
|
||||
) -> Result<ExternalGenerationJob, String> {
|
||||
validate_required("external_generation_job.job_id", job_id)?;
|
||||
validate_required("external_generation_job.worker_id", worker_id)?;
|
||||
validate_required("external_generation_job.lease_token", lease_token)?;
|
||||
let row = ctx
|
||||
.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.find(&job_id.trim().to_string())
|
||||
.ok_or_else(|| "external_generation_job 不存在".to_string())?;
|
||||
if row.status != EXTERNAL_GENERATION_STATUS_RUNNING {
|
||||
return Err("external_generation_job 当前不是 running 状态".to_string());
|
||||
}
|
||||
if !is_external_generation_job_owned_by_worker(&row, worker_id) {
|
||||
return Err("external_generation_job worker lease 不匹配".to_string());
|
||||
}
|
||||
if !is_external_generation_job_owned_by_lease_token(&row, lease_token) {
|
||||
return Err("external_generation_job lease token 不匹配".to_string());
|
||||
}
|
||||
if !is_external_generation_job_lease_active(&row, ctx.timestamp) {
|
||||
return Err("external_generation_job lease 已过期".to_string());
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn is_external_generation_job_owned_by_worker(
|
||||
row: &ExternalGenerationJob,
|
||||
worker_id: &str,
|
||||
) -> bool {
|
||||
row.worker_id.as_deref() == Some(worker_id.trim())
|
||||
}
|
||||
|
||||
fn is_external_generation_job_owned_by_lease_token(
|
||||
row: &ExternalGenerationJob,
|
||||
lease_token: &str,
|
||||
) -> bool {
|
||||
row.lease_token.as_deref() == Some(lease_token.trim())
|
||||
}
|
||||
|
||||
fn is_external_generation_job_lease_active(row: &ExternalGenerationJob, now: Timestamp) -> bool {
|
||||
row.lease_expires_at
|
||||
.map(|lease_expires_at| lease_expires_at > now)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_external_generation_job_claimable(row: &ExternalGenerationJob, now: Timestamp) -> bool {
|
||||
match row.status.as_str() {
|
||||
EXTERNAL_GENERATION_STATUS_PENDING => row.available_at <= now,
|
||||
EXTERNAL_GENERATION_STATUS_RUNNING => row
|
||||
.lease_expires_at
|
||||
.map(|lease_expires_at| lease_expires_at <= now)
|
||||
.unwrap_or(true),
|
||||
EXTERNAL_GENERATION_STATUS_COMPLETED
|
||||
| EXTERNAL_GENERATION_STATUS_FAILED
|
||||
| EXTERNAL_GENERATION_STATUS_CANCELLED => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_external_generation_claimable_age(
|
||||
stats: &mut ExternalGenerationQueueStatsSnapshot,
|
||||
row: &ExternalGenerationJob,
|
||||
now_micros: i64,
|
||||
) {
|
||||
let age = now_micros
|
||||
.saturating_sub(row.available_at.to_micros_since_unix_epoch())
|
||||
.max(0);
|
||||
stats.oldest_claimable_age_micros = Some(
|
||||
stats
|
||||
.oldest_claimable_age_micros
|
||||
.map(|current| current.max(age))
|
||||
.unwrap_or(age),
|
||||
);
|
||||
}
|
||||
|
||||
fn persist_external_generation_job_row(ctx: &ReducerContext, row: ExternalGenerationJob) {
|
||||
ctx.db
|
||||
.external_generation_job()
|
||||
.job_id()
|
||||
.delete(&row.job_id);
|
||||
ctx.db.external_generation_job().insert(row);
|
||||
}
|
||||
|
||||
fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenerationJobSnapshot {
|
||||
ExternalGenerationJobSnapshot {
|
||||
job_id: row.job_id,
|
||||
dedupe_key: row.dedupe_key,
|
||||
job_kind: row.job_kind,
|
||||
owner_user_id: row.owner_user_id,
|
||||
source_module: row.source_module,
|
||||
source_entity_id: row.source_entity_id,
|
||||
request_label: row.request_label,
|
||||
request_payload_json: row.request_payload_json,
|
||||
status: row.status,
|
||||
attempt: row.attempt,
|
||||
max_attempts: row.max_attempts,
|
||||
last_error_message: row.last_error_message,
|
||||
worker_id: row.worker_id,
|
||||
lease_expires_at_micros: row
|
||||
.lease_expires_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
available_at_micros: row.available_at.to_micros_since_unix_epoch(),
|
||||
result_payload_json: row.result_payload_json,
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
started_at_micros: row
|
||||
.started_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
completed_at_micros: row
|
||||
.completed_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
lease_token: row.lease_token,
|
||||
}
|
||||
}
|
||||
|
||||
fn single_external_generation_job_result(
|
||||
job: ExternalGenerationJobSnapshot,
|
||||
) -> ExternalGenerationJobProcedureResult {
|
||||
ExternalGenerationJobProcedureResult {
|
||||
ok: true,
|
||||
job: Some(job),
|
||||
jobs: Vec::new(),
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_external_generation_job_result(message: String) -> ExternalGenerationJobProcedureResult {
|
||||
ExternalGenerationJobProcedureResult {
|
||||
ok: false,
|
||||
job: None,
|
||||
jobs: Vec::new(),
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_required(field: &str, value: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{field} 不能为空"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn duration_between_micros(later: i64, earlier: i64, field: &str) -> Result<i64, String> {
|
||||
let duration = later.saturating_sub(earlier);
|
||||
if duration <= 0 {
|
||||
return Err(format!("{field} 必须大于 0"));
|
||||
}
|
||||
Ok(duration)
|
||||
}
|
||||
|
||||
fn timestamp_after_micros(timestamp: Timestamp, duration_micros: i64) -> Timestamp {
|
||||
Timestamp::from_micros_since_unix_epoch(
|
||||
timestamp
|
||||
.to_micros_since_unix_epoch()
|
||||
.saturating_add(duration_micros.max(0)),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_external_generation_lease_token(
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
attempt: u32,
|
||||
claimed_at: Timestamp,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
job_id.trim(),
|
||||
worker_id.trim(),
|
||||
attempt,
|
||||
claimed_at.to_micros_since_unix_epoch()
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: &str) -> Option<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_generation_job_result_failure_is_structured() {
|
||||
let result = failed_external_generation_job_result("失败".to_string());
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.error_message.as_deref(), Some("失败"));
|
||||
assert!(result.jobs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_job_is_claimable_only_after_available_time() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING);
|
||||
row.available_at = micros(1_000);
|
||||
|
||||
assert!(!is_external_generation_job_claimable(&row, micros(999)));
|
||||
assert!(is_external_generation_job_claimable(&row, micros(1_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_job_is_claimable_only_after_lease_expires() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.lease_expires_at = Some(micros(2_000));
|
||||
|
||||
assert!(!is_external_generation_job_claimable(&row, micros(1_999)));
|
||||
assert!(is_external_generation_job_claimable(&row, micros(2_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_job_is_not_claimable() {
|
||||
for status in [
|
||||
EXTERNAL_GENERATION_STATUS_COMPLETED,
|
||||
EXTERNAL_GENERATION_STATUS_FAILED,
|
||||
EXTERNAL_GENERATION_STATUS_CANCELLED,
|
||||
] {
|
||||
let row = external_generation_job_fixture(status);
|
||||
|
||||
assert!(!is_external_generation_job_claimable(&row, micros(10_000)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_ownership_requires_matching_trimmed_worker_id() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.worker_id = Some("worker-a".to_string());
|
||||
|
||||
assert!(is_external_generation_job_owned_by_worker(
|
||||
&row,
|
||||
" worker-a "
|
||||
));
|
||||
assert!(!is_external_generation_job_owned_by_worker(
|
||||
&row, "worker-b"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_ownership_requires_matching_trimmed_lease_token() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.lease_token = Some("job-1:worker-a:1:1000".to_string());
|
||||
|
||||
assert!(is_external_generation_job_owned_by_lease_token(
|
||||
&row,
|
||||
" job-1:worker-a:1:1000 "
|
||||
));
|
||||
assert!(!is_external_generation_job_owned_by_lease_token(
|
||||
&row,
|
||||
"job-1:worker-a:2:2000"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lease_is_active_only_before_expiry() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
row.lease_expires_at = Some(micros(2_000));
|
||||
|
||||
assert!(is_external_generation_job_lease_active(&row, micros(1_999)));
|
||||
assert!(!is_external_generation_job_lease_active(
|
||||
&row,
|
||||
micros(2_000)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lease_token_changes_with_claim_attempt() {
|
||||
let first =
|
||||
build_external_generation_lease_token("extgen-test", "worker-a", 1, micros(1_000));
|
||||
let second =
|
||||
build_external_generation_lease_token("extgen-test", "worker-a", 2, micros(2_000));
|
||||
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimable_age_keeps_oldest_available_job() {
|
||||
let mut stats = ExternalGenerationQueueStatsSnapshot {
|
||||
pending_count: 0,
|
||||
delayed_pending_count: 0,
|
||||
claimable_pending_count: 0,
|
||||
running_active_count: 0,
|
||||
expired_running_count: 0,
|
||||
terminal_count: 0,
|
||||
claimable_count: 0,
|
||||
oldest_claimable_age_micros: None,
|
||||
now_micros: 10_000,
|
||||
};
|
||||
let mut old_job = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING);
|
||||
old_job.available_at = micros(1_000);
|
||||
let mut newer_job = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
newer_job.available_at = micros(8_000);
|
||||
|
||||
record_external_generation_claimable_age(&mut stats, &newer_job, 10_000);
|
||||
record_external_generation_claimable_age(&mut stats, &old_job, 10_000);
|
||||
|
||||
assert_eq!(stats.oldest_claimable_age_micros, Some(9_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_duration_between_client_times_is_preserved() {
|
||||
assert_eq!(
|
||||
duration_between_micros(3_500, 1_000, "external_generation_job.lease_duration"),
|
||||
Ok(2_500),
|
||||
);
|
||||
assert!(duration_between_micros(1_000, 1_000, "duration").is_err());
|
||||
}
|
||||
|
||||
fn external_generation_job_fixture(status: &str) -> ExternalGenerationJob {
|
||||
ExternalGenerationJob {
|
||||
job_id: "extgen-test".to_string(),
|
||||
dedupe_key: "puzzle:compile:test".to_string(),
|
||||
job_kind: "puzzle_compile_draft".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_module: "puzzle".to_string(),
|
||||
source_entity_id: "session-1".to_string(),
|
||||
request_label: "拼图首关草稿生成".to_string(),
|
||||
request_payload_json: r#"{"sessionId":"session-1"}"#.to_string(),
|
||||
status: status.to_string(),
|
||||
attempt: 0,
|
||||
max_attempts: 1,
|
||||
last_error_message: None,
|
||||
worker_id: None,
|
||||
lease_expires_at: None,
|
||||
available_at: micros(0),
|
||||
result_payload_json: None,
|
||||
created_at: micros(0),
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
updated_at: micros(0),
|
||||
lease_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn micros(value: i64) -> Timestamp {
|
||||
Timestamp::from_micros_since_unix_epoch(value)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod custom_world;
|
||||
mod domain_types;
|
||||
mod editor_project_storage;
|
||||
mod entry;
|
||||
mod external_generation;
|
||||
mod gameplay;
|
||||
mod jump_hop;
|
||||
mod match3d;
|
||||
@@ -53,6 +54,7 @@ pub use custom_world::*;
|
||||
pub use domain_types::*;
|
||||
pub use editor_project_storage::*;
|
||||
pub use entry::*;
|
||||
pub use external_generation::*;
|
||||
pub use gameplay::*;
|
||||
pub use jump_hop::*;
|
||||
pub use match3d::*;
|
||||
|
||||
@@ -183,6 +183,7 @@ macro_rules! migration_tables {
|
||||
ai_text_chunk,
|
||||
ai_result_reference,
|
||||
ai_task_event,
|
||||
external_generation_job,
|
||||
runtime_snapshot,
|
||||
runtime_setting,
|
||||
creation_entry_config,
|
||||
|
||||
@@ -14,12 +14,12 @@ use module_puzzle::{
|
||||
PuzzleBackgroundCompileTaskProcedureResult, PuzzleBackgroundCompileTaskReleaseInput,
|
||||
PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
|
||||
PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput,
|
||||
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
|
||||
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
|
||||
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
|
||||
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
|
||||
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleLeaderboardSubmitInput, PuzzleLevelGenerationFailureInput, PuzzlePublicationStatus,
|
||||
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
|
||||
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput,
|
||||
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
|
||||
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
|
||||
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
|
||||
@@ -38,10 +38,15 @@ use spacetimedb::{
|
||||
};
|
||||
|
||||
use crate::auth::user_account;
|
||||
use crate::validate_external_generation_job_lease_for_tx;
|
||||
|
||||
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
|
||||
const PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS: i64 = 30 * 60 * 1_000_000;
|
||||
const WORK_VISIBLE_DEFAULT: bool = true;
|
||||
const PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE: &str = "puzzle";
|
||||
const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
|
||||
const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
|
||||
const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
|
||||
|
||||
/// 拼图 Agent session 真相表。
|
||||
/// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。
|
||||
@@ -407,6 +412,25 @@ pub fn mark_puzzle_draft_generation_failed(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn mark_puzzle_level_generation_failed(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
) -> PuzzleAgentSessionProcedureResult {
|
||||
match ctx.try_with_tx(|tx| mark_puzzle_level_generation_failed_tx(tx, input.clone())) {
|
||||
Ok(session) => PuzzleAgentSessionProcedureResult {
|
||||
ok: true,
|
||||
session: Some(session),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||||
ok: false,
|
||||
session: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn claim_puzzle_background_compile_task(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -444,7 +468,6 @@ pub fn release_puzzle_background_compile_task(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存拼图入口表单草稿。
|
||||
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
|
||||
#[spacetimedb::procedure]
|
||||
@@ -1035,6 +1058,17 @@ fn compile_puzzle_agent_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
None,
|
||||
"拼图草稿编译外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
if row.seed_text.trim().is_empty() {
|
||||
return Err("请先填写拼图作品信息".to_string());
|
||||
@@ -1163,6 +1197,17 @@ fn mark_puzzle_draft_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
None,
|
||||
"拼图草稿失败态外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
|
||||
let draft = match deserialize_optional_draft(&row.draft_json)? {
|
||||
@@ -1214,6 +1259,89 @@ fn mark_puzzle_draft_generation_failed_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_level_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
|
||||
],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
"拼图关卡失败态外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
|
||||
let mut draft = match deserialize_optional_draft(&row.draft_json)? {
|
||||
Some(draft) => draft,
|
||||
None => {
|
||||
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
|
||||
let messages = list_session_messages(ctx, &row.session_id);
|
||||
compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text))
|
||||
}
|
||||
};
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:新增关卡可能还没完成自动保存,失败回写必须以本次 action 快照作为目标集合。
|
||||
draft.levels = levels;
|
||||
}
|
||||
draft = mark_puzzle_level_generation_failed_draft(draft, input.level_id.as_deref())?;
|
||||
let next_stage = resolve_failed_puzzle_agent_stage(row.stage, &draft);
|
||||
upsert_puzzle_draft_work_profile(
|
||||
ctx,
|
||||
&row.session_id,
|
||||
&row.owner_user_id,
|
||||
&draft,
|
||||
input.failed_at_micros,
|
||||
)?;
|
||||
|
||||
replace_puzzle_agent_session(
|
||||
ctx,
|
||||
&row,
|
||||
PuzzleAgentSessionRow {
|
||||
session_id: row.session_id.clone(),
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
seed_text: row.seed_text.clone(),
|
||||
current_turn: row.current_turn,
|
||||
progress_percent: row.progress_percent.max(94),
|
||||
stage: next_stage,
|
||||
anchor_pack_json: row.anchor_pack_json.clone(),
|
||||
draft_json: Some(serialize_json(&draft)),
|
||||
last_assistant_reply: Some(input.error_message),
|
||||
published_profile_id: row.published_profile_id.clone(),
|
||||
created_at: row.created_at,
|
||||
updated_at,
|
||||
},
|
||||
);
|
||||
|
||||
get_puzzle_agent_session_tx(
|
||||
ctx,
|
||||
PuzzleAgentSessionGetInput {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_level_generation_failed_draft(
|
||||
draft: PuzzleResultDraft,
|
||||
level_id: Option<&str>,
|
||||
) -> Result<PuzzleResultDraft, String> {
|
||||
let target_level =
|
||||
selected_puzzle_level(&draft, level_id).ok_or_else(|| "拼图关卡不存在".to_string())?;
|
||||
let mut next_level = target_level;
|
||||
next_level.generation_status = "failed".to_string();
|
||||
let mut draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
fn resolve_failed_puzzle_agent_stage(
|
||||
current_stage: PuzzleAgentStage,
|
||||
draft: &PuzzleResultDraft,
|
||||
@@ -1229,6 +1357,63 @@ fn resolve_failed_puzzle_agent_stage(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_puzzle_external_generation_write_guard(
|
||||
ctx: &TxContext,
|
||||
job_id: &str,
|
||||
worker_id: &str,
|
||||
lease_token: &str,
|
||||
expected_job_kinds: &[&str],
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
level_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let session_entity_id = session_id.trim().to_string();
|
||||
let mut source_entity_ids = vec![session_entity_id.clone()];
|
||||
if let Some(level_id) = level_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
source_entity_ids.push(format!("{session_entity_id}:{level_id}"));
|
||||
}
|
||||
|
||||
validate_external_generation_job_lease_for_tx(
|
||||
ctx.as_ref(),
|
||||
job_id,
|
||||
worker_id,
|
||||
lease_token,
|
||||
expected_job_kinds,
|
||||
owner_user_id,
|
||||
PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE,
|
||||
&source_entity_ids,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx: &TxContext,
|
||||
job_id: Option<&str>,
|
||||
worker_id: Option<&str>,
|
||||
lease_token: Option<&str>,
|
||||
expected_job_kinds: &[&str],
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
level_id: Option<&str>,
|
||||
incomplete_message: &str,
|
||||
) -> Result<(), String> {
|
||||
match (job_id, worker_id, lease_token) {
|
||||
(Some(job_id), Some(worker_id), Some(lease_token)) => {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
job_id,
|
||||
worker_id,
|
||||
lease_token,
|
||||
expected_job_kinds,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
level_id,
|
||||
)
|
||||
}
|
||||
(None, None, None) => Ok(()),
|
||||
_ => Err(incomplete_message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_puzzle_form_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleFormDraftSaveInput,
|
||||
@@ -1286,6 +1471,20 @@ fn save_puzzle_generated_images_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleGeneratedImagesSaveInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[
|
||||
PUZZLE_COMPILE_DRAFT_JOB_KIND,
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
"拼图图片保存外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
let previous_primary_level_name = draft.level_name.clone();
|
||||
@@ -1370,6 +1569,17 @@ fn save_puzzle_ui_background_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleUiBackgroundSaveInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
"拼图 UI 背景保存外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
@@ -4040,6 +4250,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_generation_failure_only_marks_target_level_failed() {
|
||||
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
|
||||
let mut draft = compile_result_draft_from_seed(
|
||||
&anchor_pack,
|
||||
&[],
|
||||
Some("画面描述:一只猫在雨夜灯牌下回头。"),
|
||||
);
|
||||
draft.levels[0].generation_status = "ready".to_string();
|
||||
draft.levels[0].cover_image_src = Some("/generated-puzzle-assets/first.png".to_string());
|
||||
let mut second_level = draft.levels[0].clone();
|
||||
second_level.level_id = "puzzle-level-2".to_string();
|
||||
second_level.level_name = "第二关".to_string();
|
||||
second_level.picture_description = "第二关画面".to_string();
|
||||
second_level.cover_image_src = None;
|
||||
second_level.cover_asset_id = None;
|
||||
second_level.candidates = Vec::new();
|
||||
second_level.selected_candidate_id = None;
|
||||
second_level.generation_status = "generating".to_string();
|
||||
draft.levels.push(second_level);
|
||||
|
||||
let failed = mark_puzzle_level_generation_failed_draft(draft, Some("puzzle-level-2"))
|
||||
.expect("target level should be marked failed");
|
||||
|
||||
assert_eq!(failed.levels[0].generation_status, "ready");
|
||||
assert_eq!(
|
||||
failed.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/first.png")
|
||||
);
|
||||
assert_eq!(failed.levels[1].generation_status, "failed");
|
||||
assert_eq!(failed.generation_status, "ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
||||
let left = PuzzleWorkProfile {
|
||||
|
||||
@@ -345,6 +345,9 @@ fn compile_puzzle_clear_draft_tx(
|
||||
if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) {
|
||||
return mark_puzzle_clear_generation_failed_tx(ctx, input, session);
|
||||
}
|
||||
if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_GENERATING) {
|
||||
return mark_puzzle_clear_generation_generating_tx(ctx, input, session);
|
||||
}
|
||||
let pattern_groups: Vec<PuzzleClearPatternGroupSnapshot> = input
|
||||
.pattern_groups_json
|
||||
.as_deref()
|
||||
@@ -457,6 +460,71 @@ fn compile_puzzle_clear_draft_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_clear_generation_generating_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: PuzzleClearDraftCompileInput,
|
||||
session: PuzzleClearAgentSessionRow,
|
||||
) -> Result<PuzzleClearAgentSessionSnapshot, String> {
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||||
let mut draft = if session.draft_json.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_json::<PuzzleClearDraftSnapshot>(&session.draft_json).ok()
|
||||
}
|
||||
.unwrap_or_else(|| PuzzleClearDraftSnapshot {
|
||||
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
||||
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
||||
profile_id: Some(input.profile_id.clone()),
|
||||
work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME),
|
||||
work_description: input.work_description.trim().to_string(),
|
||||
theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME),
|
||||
generate_board_background: input.generate_board_background,
|
||||
board_background_asset: None,
|
||||
board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt),
|
||||
card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
|
||||
atlas_asset: None,
|
||||
pattern_groups: Vec::new(),
|
||||
card_assets: Vec::new(),
|
||||
generation_status: PUZZLE_CLEAR_GENERATION_GENERATING.to_string(),
|
||||
});
|
||||
draft.profile_id = Some(input.profile_id.clone());
|
||||
draft.work_title = clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME);
|
||||
draft.work_description = input.work_description.trim().to_string();
|
||||
draft.theme_prompt = clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME);
|
||||
draft.generate_board_background = input.generate_board_background;
|
||||
draft.board_background_prompt =
|
||||
clean_string(&input.board_background_prompt, &input.theme_prompt);
|
||||
if let Some(board_background_asset) = input
|
||||
.board_background_asset_json
|
||||
.as_deref()
|
||||
.map(parse_json)
|
||||
.transpose()?
|
||||
{
|
||||
draft.board_background_asset = Some(board_background_asset);
|
||||
}
|
||||
draft.generation_status = PUZZLE_CLEAR_GENERATION_GENERATING.to_string();
|
||||
|
||||
replace_session(
|
||||
ctx,
|
||||
&session,
|
||||
PuzzleClearAgentSessionRow {
|
||||
status: PUZZLE_CLEAR_GENERATION_GENERATING.to_string(),
|
||||
draft_json: to_json_string(&draft),
|
||||
published_profile_id: input.profile_id,
|
||||
updated_at,
|
||||
..clone_session(&session)
|
||||
},
|
||||
);
|
||||
|
||||
get_puzzle_clear_agent_session_tx(
|
||||
ctx,
|
||||
PuzzleClearAgentSessionGetInput {
|
||||
session_id: input.session_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_puzzle_clear_generation_failed_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: PuzzleClearDraftCompileInput,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
|
||||
pub const PUZZLE_CLEAR_PUBLICATION_DRAFT: &str = "draft";
|
||||
pub const PUZZLE_CLEAR_PUBLICATION_PUBLISHED: &str = "published";
|
||||
pub const PUZZLE_CLEAR_GENERATION_DRAFT: &str = "draft";
|
||||
pub const PUZZLE_CLEAR_GENERATION_GENERATING: &str = "generating";
|
||||
pub const PUZZLE_CLEAR_GENERATION_READY: &str = "ready";
|
||||
pub const PUZZLE_CLEAR_GENERATION_FAILED: &str = "failed";
|
||||
pub const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp";
|
||||
|
||||
Reference in New Issue
Block a user