1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
530 lines
16 KiB
Rust
530 lines
16 KiB
Rust
//! 产品事件合同。正文和凭据不属于此模块的输入,nullable 字段仍必须显式存在。
|
|
use chrono::{DateTime, SecondsFormat, Utc};
|
|
use serde::{Deserialize, Deserializer, Serialize};
|
|
use serde_json::Value;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use uuid::Uuid;
|
|
|
|
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
|
|
|
fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
T: Deserialize<'de>,
|
|
{
|
|
Option::<T>::deserialize(deserializer)
|
|
}
|
|
|
|
macro_rules! values {
|
|
($name:ident { $($variant:ident),+ $(,)? }) => {
|
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub(crate) enum $name { $($variant),+ }
|
|
};
|
|
}
|
|
|
|
values!(Source {
|
|
Editor,
|
|
Direct,
|
|
DesignAgent,
|
|
AssetCanvas,
|
|
ResourceEditor,
|
|
UiEditor,
|
|
Manual,
|
|
System
|
|
});
|
|
values!(Status { Success, Failed });
|
|
values!(EntrySource {
|
|
DirectLaunch,
|
|
ProjectAssociation,
|
|
AppRestore
|
|
});
|
|
values!(SessionEndReason {
|
|
UserExit,
|
|
AppRestart
|
|
});
|
|
values!(FocusReason {
|
|
InitialFocus,
|
|
WindowFocus,
|
|
Restore,
|
|
AccountChange
|
|
});
|
|
values!(BlurReason {
|
|
WindowBlur,
|
|
Minimized,
|
|
AppExit,
|
|
SystemSuspend,
|
|
AccountChange
|
|
});
|
|
values!(CreationSource {
|
|
HomeGame,
|
|
HomeDesign,
|
|
Template,
|
|
SelectedDirectory
|
|
});
|
|
values!(OpenSource {
|
|
Create,
|
|
Picker,
|
|
Recent,
|
|
AppRestore,
|
|
ProjectAssociation
|
|
});
|
|
values!(AgentType {
|
|
GameAgent,
|
|
DesignAgent
|
|
});
|
|
values!(RunSource {
|
|
UserSubmit,
|
|
UserContinue,
|
|
Clarification,
|
|
Approval,
|
|
UserRetry
|
|
});
|
|
values!(RunEndReason {
|
|
Finished,
|
|
WaitingForUser,
|
|
WaitingForApproval,
|
|
Failed
|
|
});
|
|
values!(ErrorCode {
|
|
ProviderAuthFailed,
|
|
ProviderRateLimited,
|
|
ProviderUnavailable,
|
|
ProviderTimeout,
|
|
ProviderInvalidResponse,
|
|
LocalIoFailed,
|
|
RuntimeFailed,
|
|
RuntimeErrorUnclassified
|
|
});
|
|
values!(RevisionSource {
|
|
Agent,
|
|
AssetCanvas,
|
|
ResourceEditor,
|
|
UiEditor,
|
|
ManualEdit,
|
|
SystemProjection
|
|
});
|
|
values!(ChangeKind {
|
|
Code,
|
|
Asset,
|
|
Ui,
|
|
DesignDocument,
|
|
Mixed
|
|
});
|
|
values!(PreviewSource {
|
|
User,
|
|
Agent,
|
|
AutoRestore
|
|
});
|
|
values!(SaveSource {
|
|
Manual,
|
|
Auto,
|
|
Checkpoint
|
|
});
|
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct Route {
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub destination_origin: Option<String>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub user_id: Option<String>,
|
|
}
|
|
|
|
impl Route {
|
|
pub fn from_identity(user_id: Option<String>, api_base_url: Option<&str>) -> Self {
|
|
let destination_origin = api_base_url.and_then(|raw| {
|
|
let parsed = url::Url::parse(raw).ok()?;
|
|
(matches!(parsed.scheme(), "http" | "https")
|
|
&& parsed.host_str().is_some()
|
|
&& parsed.username().is_empty()
|
|
&& parsed.password().is_none())
|
|
.then(|| parsed.origin().ascii_serialization())
|
|
});
|
|
Self {
|
|
user_id,
|
|
destination_origin,
|
|
}
|
|
}
|
|
|
|
pub fn validate(&self) -> bool {
|
|
optional_id(&self.user_id)
|
|
&& self.destination_origin.as_ref().is_none_or(|origin| {
|
|
origin.len() <= 2048
|
|
&& Self::from_identity(None, Some(origin))
|
|
.destination_origin
|
|
.as_ref()
|
|
== Some(origin)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct SessionStart {
|
|
pub entry_source: EntrySource,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub first_project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct SessionEnd {
|
|
pub end_reason: SessionEndReason,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub session_duration_ms: Option<u64>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub last_project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct FocusStart {
|
|
pub focus_interval_id: String,
|
|
pub focus_reason: FocusReason,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub active_project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct FocusEnd {
|
|
pub focus_interval_id: String,
|
|
pub blur_reason: BlurReason,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub focus_duration_ms: Option<u64>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub active_project_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct ProjectCreated {
|
|
pub creation_source: CreationSource,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub project_template_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct ProjectOpened {
|
|
pub open_source: OpenSource,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub is_first_open: Option<bool>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct EmptyProperties {}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct RunFinished {
|
|
pub agent_type: AgentType,
|
|
pub run_source: RunSource,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub duration_ms: Option<u64>,
|
|
pub retry_index: u64,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub output_change_detected: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub revision_id: Option<String>,
|
|
pub end_reason: RunEndReason,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct RevisionCreated {
|
|
pub revision_id: String,
|
|
pub revision_source: RevisionSource,
|
|
pub change_kind: ChangeKind,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub files_changed_count: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct PreviewReady {
|
|
pub preview_source: PreviewSource,
|
|
pub preview_version: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub ready_duration_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct ProjectSaved {
|
|
pub save_source: SaveSource,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub revision_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(
|
|
tag = "event_name",
|
|
content = "properties",
|
|
rename_all = "snake_case",
|
|
deny_unknown_fields
|
|
)]
|
|
pub(crate) enum EventData {
|
|
EditorSessionStart(SessionStart),
|
|
EditorSessionEnd(SessionEnd),
|
|
EditorFocusStart(FocusStart),
|
|
EditorFocusEnd(FocusEnd),
|
|
ProjectCreateSuccess(ProjectCreated),
|
|
ProjectOpen(ProjectOpened),
|
|
CreativeTaskSubmit(EmptyProperties),
|
|
AgentRunCompleted(RunFinished),
|
|
AgentRunFailed(RunFinished),
|
|
ProjectRevisionCreated(RevisionCreated),
|
|
PreviewReady(PreviewReady),
|
|
ProjectSave(ProjectSaved),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct Event {
|
|
pub schema_version: u32,
|
|
pub event_id: String,
|
|
pub event_name: String,
|
|
pub event_time: String,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub user_id: Option<String>,
|
|
pub editor_session_id: String,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub project_id: Option<String>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub creative_task_id: Option<String>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub agent_run_id: Option<String>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub agent_turn_id: Option<String>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub status: Option<Status>,
|
|
#[serde(deserialize_with = "required_nullable")]
|
|
pub error_code: Option<ErrorCode>,
|
|
pub source: Source,
|
|
pub client_version: String,
|
|
pub properties: Value,
|
|
}
|
|
|
|
/// 每次操作先冻结此上下文。它不持有登录凭据,也不在后台重新读取当前账号。
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub(crate) struct Context {
|
|
pub route: Route,
|
|
pub editor_session_id: String,
|
|
pub client_version: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct RunIdentity {
|
|
pub run_id: String,
|
|
pub turn_id: Option<String>,
|
|
pub error_code: Option<ErrorCode>,
|
|
}
|
|
|
|
impl Context {
|
|
pub fn capture(
|
|
&self,
|
|
data: EventData,
|
|
project_id: Option<String>,
|
|
source: Source,
|
|
run: Option<RunIdentity>,
|
|
) -> Result<Event, &'static str> {
|
|
if !self.route.validate() {
|
|
return Err("invalid_route");
|
|
}
|
|
let status = data.status();
|
|
let creative_task_id = data.has_goal().then(|| project_id.clone()).flatten();
|
|
let payload = serde_json::to_value(data).map_err(|_| "serialize_failed")?;
|
|
let event = Event {
|
|
schema_version: 1,
|
|
event_id: Uuid::new_v4().to_string(),
|
|
event_name: payload["event_name"]
|
|
.as_str()
|
|
.ok_or("invalid_event")?
|
|
.to_string(),
|
|
event_time: timestamp_now(),
|
|
user_id: self.route.user_id.clone(),
|
|
editor_session_id: self.editor_session_id.clone(),
|
|
project_id,
|
|
creative_task_id,
|
|
agent_run_id: run.as_ref().map(|r| r.run_id.clone()),
|
|
agent_turn_id: run.as_ref().and_then(|r| r.turn_id.clone()),
|
|
error_code: run.and_then(|r| r.error_code),
|
|
status,
|
|
source,
|
|
client_version: self.client_version.clone(),
|
|
properties: payload["properties"].clone(),
|
|
};
|
|
event.validate()?;
|
|
Ok(event)
|
|
}
|
|
}
|
|
|
|
impl EventData {
|
|
fn status(&self) -> Option<Status> {
|
|
match self {
|
|
Self::EditorFocusStart(_) | Self::EditorFocusEnd(_) => None,
|
|
Self::AgentRunFailed(_) => Some(Status::Failed),
|
|
_ => Some(Status::Success),
|
|
}
|
|
}
|
|
|
|
fn has_goal(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::CreativeTaskSubmit(_)
|
|
| Self::AgentRunCompleted(_)
|
|
| Self::AgentRunFailed(_)
|
|
| Self::ProjectRevisionCreated(_)
|
|
| Self::PreviewReady(_)
|
|
| Self::ProjectSave(_)
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Event {
|
|
pub fn data(&self) -> Result<EventData, &'static str> {
|
|
// 可选字段不可得时省略;显式 null 仅用于合同指定的 nullable 字段。
|
|
let optional = match self.event_name.as_str() {
|
|
"project_create_success" => &["project_template_id"][..],
|
|
"agent_run_completed" | "agent_run_failed" | "project_save" => &["revision_id"][..],
|
|
"project_revision_created" => &["files_changed_count"][..],
|
|
"preview_ready" => &["ready_duration_ms"][..],
|
|
_ => &[][..],
|
|
};
|
|
if optional
|
|
.iter()
|
|
.any(|key| self.properties.get(key).is_some_and(Value::is_null))
|
|
{
|
|
return Err("invalid_optional_property");
|
|
}
|
|
serde_json::from_value(serde_json::json!({
|
|
"event_name": self.event_name,
|
|
"properties": self.properties,
|
|
}))
|
|
.map_err(|_| "invalid_properties")
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), &'static str> {
|
|
if self.schema_version != 1
|
|
|| !uuid(&self.event_id)
|
|
|| !uuid(&self.editor_session_id)
|
|
|| !id(&self.client_version)
|
|
|| !optional_id(&self.user_id)
|
|
|| !optional_id(&self.project_id)
|
|
|| !optional_id(&self.creative_task_id)
|
|
|| !optional_id(&self.agent_turn_id)
|
|
|| !valid_time(&self.event_time)
|
|
{
|
|
return Err("invalid_envelope");
|
|
}
|
|
let data = self.data()?;
|
|
if self.status != data.status()
|
|
|| self.error_code.is_some() != matches!(data, EventData::AgentRunFailed(_))
|
|
{
|
|
return Err("invalid_status");
|
|
}
|
|
if data.has_goal() {
|
|
if self.project_id.is_none() || self.creative_task_id != self.project_id {
|
|
return Err("invalid_goal");
|
|
}
|
|
} else if self.creative_task_id.is_some() {
|
|
return Err("unexpected_goal");
|
|
}
|
|
let is_run = matches!(
|
|
data,
|
|
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
|
|
);
|
|
if is_run {
|
|
if !self.agent_run_id.as_deref().is_some_and(uuid) {
|
|
return Err("invalid_run");
|
|
}
|
|
} else if self.agent_run_id.is_some() || self.agent_turn_id.is_some() {
|
|
return Err("unexpected_run");
|
|
}
|
|
let editor = self.source == Source::Editor;
|
|
let agent = matches!(self.source, Source::Direct | Source::DesignAgent);
|
|
let valid = match data {
|
|
EventData::EditorSessionStart(p) => editor && p.first_project_id == self.project_id,
|
|
EventData::EditorSessionEnd(p) => {
|
|
editor && p.last_project_id == self.project_id && safe(p.session_duration_ms)
|
|
}
|
|
EventData::EditorFocusStart(p) => {
|
|
editor && uuid(&p.focus_interval_id) && p.active_project_id == self.project_id
|
|
}
|
|
EventData::EditorFocusEnd(p) => {
|
|
editor
|
|
&& uuid(&p.focus_interval_id)
|
|
&& p.active_project_id == self.project_id
|
|
&& safe(p.focus_duration_ms)
|
|
}
|
|
EventData::ProjectCreateSuccess(p) => {
|
|
editor && self.project_id.is_some() && optional_id(&p.project_template_id)
|
|
}
|
|
EventData::ProjectOpen(_) => editor && self.project_id.is_some(),
|
|
EventData::CreativeTaskSubmit(_) => agent,
|
|
EventData::AgentRunCompleted(p) | EventData::AgentRunFailed(p) => {
|
|
agent
|
|
&& ((self.source == Source::Direct) == (p.agent_type == AgentType::GameAgent))
|
|
&& ((self.status == Some(Status::Failed))
|
|
== (p.end_reason == RunEndReason::Failed))
|
|
&& safe(p.duration_ms)
|
|
&& safe(Some(p.retry_index))
|
|
&& optional_id(&p.revision_id)
|
|
}
|
|
EventData::ProjectRevisionCreated(p) => {
|
|
id(&p.revision_id)
|
|
&& safe(p.files_changed_count)
|
|
&& match p.revision_source {
|
|
RevisionSource::Agent => agent,
|
|
RevisionSource::AssetCanvas => self.source == Source::AssetCanvas,
|
|
RevisionSource::ResourceEditor => self.source == Source::ResourceEditor,
|
|
RevisionSource::UiEditor => self.source == Source::UiEditor,
|
|
RevisionSource::ManualEdit => self.source == Source::Manual,
|
|
RevisionSource::SystemProjection => self.source == Source::System,
|
|
}
|
|
}
|
|
EventData::PreviewReady(p) => id(&p.preview_version) && safe(p.ready_duration_ms),
|
|
EventData::ProjectSave(p) => optional_id(&p.revision_id),
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err("invalid_event_fields")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn id(value: &str) -> bool {
|
|
!value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
|
|
}
|
|
fn optional_id(value: &Option<String>) -> bool {
|
|
value.as_deref().is_none_or(id)
|
|
}
|
|
fn uuid(value: &str) -> bool {
|
|
Uuid::parse_str(value).is_ok_and(|v| v.get_version_num() == 4 && v.to_string() == value)
|
|
}
|
|
fn safe(value: Option<u64>) -> bool {
|
|
value.is_none_or(|v| v <= MAX_SAFE_INTEGER)
|
|
}
|
|
pub(super) fn valid_time(value: &str) -> bool {
|
|
DateTime::parse_from_rfc3339(value)
|
|
.is_ok_and(|v| v.to_utc().to_rfc3339_opts(SecondsFormat::Millis, true) == value)
|
|
}
|
|
|
|
pub(crate) fn timestamp_now() -> String {
|
|
let ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis();
|
|
DateTime::<Utc>::from_timestamp_millis(ms.min(i64::MAX as u128) as i64)
|
|
.unwrap_or_default()
|
|
.to_rfc3339_opts(SecondsFormat::Millis, true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "contract_tests.rs"]
|
|
mod tests;
|