Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f983663caf | |||
| 9f225b7a41 | |||
| 2d61a4c057 | |||
| e2bdb1105b | |||
| 71f5f36764 | |||
| f71361c292 | |||
| 6e9e7edcf8 | |||
| 480fe995ed | |||
| afd5b8d91f | |||
| c60d116af0 | |||
| 3eb99f5124 | |||
| 34b1ad5d5c | |||
| 5087b600a2 | |||
| 8fd85d0426 | |||
| b840973d82 | |||
| ca82e6b745 | |||
| 3e657e6a6a |
@@ -81,14 +81,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
|
||||
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
|
||||
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
|
||||
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可",
|
||||
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令",
|
||||
"agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
|
||||
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
|
||||
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
"icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
|
||||
"ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}",
|
||||
"scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}",
|
||||
"default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。",
|
||||
"spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief:{}",
|
||||
"ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。",
|
||||
"default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。",
|
||||
"custom_inspection_focus": "检查重点:{question}",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
|
||||
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
|
||||
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。",
|
||||
"unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
|
||||
|
||||
@@ -7684,7 +7684,7 @@ done
|
||||
&temp.path().join("host"),
|
||||
&project,
|
||||
"turn-0001",
|
||||
"fixture-request",
|
||||
&format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
|
||||
false,
|
||||
&super::super::direct_validation::DirectValidationConfig::default(),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,10 +93,17 @@ pub(super) struct ExecutionLedger {
|
||||
pub(super) plan: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub(super) last_failed_write_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) analytics_run: Option<crate::analytics::run::Metadata>,
|
||||
}
|
||||
|
||||
struct SessionData {
|
||||
ledger: ExecutionLedger,
|
||||
analytics_capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
analytics_output_revision: Option<u64>,
|
||||
started: Instant,
|
||||
initial_elapsed_ms: u64,
|
||||
lease_started: BTreeMap<String, Instant>,
|
||||
@@ -153,6 +160,8 @@ fn executor_digest(path: &Path) -> Result<String, String> {
|
||||
|
||||
pub(super) struct ExecutionSession {
|
||||
pub(super) root: PathBuf,
|
||||
/// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。
|
||||
pub(super) newly_accepted: bool,
|
||||
state_path: PathBuf,
|
||||
_owner: File,
|
||||
data: Mutex<SessionData>,
|
||||
@@ -216,6 +225,16 @@ pub(crate) struct WritePermit {
|
||||
id: String,
|
||||
}
|
||||
impl WritePermit {
|
||||
pub(super) fn record_analytics_revision(
|
||||
&self,
|
||||
revision: u64,
|
||||
change_kind: crate::analytics::contract::ChangeKind,
|
||||
files_changed_count: u64,
|
||||
) {
|
||||
self.session
|
||||
.record_analytics_revision(revision, change_kind, files_changed_count);
|
||||
}
|
||||
|
||||
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||
let mut data = self.session.lock()?;
|
||||
self.session.tick_locked(&mut data)?;
|
||||
@@ -401,6 +420,7 @@ pub(super) async fn begin(
|
||||
prompt: &str,
|
||||
requires_contract: bool,
|
||||
config: DirectValidationConfig,
|
||||
analytics_run: Option<crate::analytics::run::Metadata>,
|
||||
) -> Result<ExecutionSessionGuard, String> {
|
||||
let root = root.to_path_buf();
|
||||
let prompt_hash = hash(prompt.as_bytes());
|
||||
@@ -408,13 +428,14 @@ pub(super) async fn begin(
|
||||
let turn = super::direct_taonier_active_invocation_id_at(&root)?;
|
||||
let host = crate::game_creator_runtime_config_dir()
|
||||
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
|
||||
open_at(
|
||||
open_with_analytics_at(
|
||||
&host.join("direct-executions"),
|
||||
&root,
|
||||
&turn,
|
||||
&prompt_hash,
|
||||
requires_contract,
|
||||
&config,
|
||||
analytics_run,
|
||||
)
|
||||
})
|
||||
.await
|
||||
@@ -462,6 +483,26 @@ pub(super) fn open_at(
|
||||
request_hash: &str,
|
||||
requires_contract: bool,
|
||||
config: &DirectValidationConfig,
|
||||
) -> Result<Arc<ExecutionSession>, String> {
|
||||
open_with_analytics_at(
|
||||
host,
|
||||
root,
|
||||
turn,
|
||||
request_hash,
|
||||
requires_contract,
|
||||
config,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn open_with_analytics_at(
|
||||
host: &Path,
|
||||
root: &Path,
|
||||
turn: &str,
|
||||
request_hash: &str,
|
||||
requires_contract: bool,
|
||||
config: &DirectValidationConfig,
|
||||
analytics_run: Option<crate::analytics::run::Metadata>,
|
||||
) -> Result<Arc<ExecutionSession>, String> {
|
||||
config.validate()?;
|
||||
let root = root
|
||||
@@ -521,6 +562,7 @@ pub(super) fn open_at(
|
||||
};
|
||||
let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
|
||||
let is_new = existing.is_none();
|
||||
let mut newly_accepted = is_new;
|
||||
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
|
||||
schema_version: SCHEMA.into(),
|
||||
client_turn_id: turn.into(),
|
||||
@@ -546,6 +588,7 @@ pub(super) fn open_at(
|
||||
delivery_reviews: 0,
|
||||
plan: None,
|
||||
last_failed_write_revision: None,
|
||||
analytics_run,
|
||||
});
|
||||
if is_new {
|
||||
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
|
||||
@@ -558,6 +601,8 @@ pub(super) fn open_at(
|
||||
512 * 1024,
|
||||
)?;
|
||||
if let Some(legacy) = legacy {
|
||||
newly_accepted = false;
|
||||
ledger.analytics_run = None;
|
||||
let used = legacy["usedRuns"]
|
||||
.as_u64()
|
||||
.and_then(|n| u32::try_from(n).ok());
|
||||
@@ -614,10 +659,13 @@ pub(super) fn open_at(
|
||||
let (changed, _) = tokio::sync::watch::channel(ledger.revision);
|
||||
let session = Arc::new(ExecutionSession {
|
||||
root,
|
||||
newly_accepted,
|
||||
state_path,
|
||||
_owner: owner,
|
||||
data: Mutex::new(SessionData {
|
||||
ledger,
|
||||
analytics_capture: None,
|
||||
analytics_output_revision: None,
|
||||
started: Instant::now(),
|
||||
initial_elapsed_ms,
|
||||
lease_started: BTreeMap::new(),
|
||||
@@ -738,6 +786,10 @@ impl ExecutionSession {
|
||||
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
|
||||
Arc::clone(&self.cancellation)
|
||||
}
|
||||
pub(super) fn was_aborted(&self) -> bool {
|
||||
self.abort_requested
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
|
||||
let mut data = self.lock()?;
|
||||
if data.ledger.phase.is_terminal() {
|
||||
@@ -765,6 +817,74 @@ impl ExecutionSession {
|
||||
self.commit(&mut data, next)?;
|
||||
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
|
||||
}
|
||||
pub(super) fn set_analytics_capture(
|
||||
&self,
|
||||
capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
) {
|
||||
let Ok(mut data) = self.data.try_lock() else {
|
||||
return;
|
||||
};
|
||||
data.analytics_capture = capture.and_then(|(mut context, writer)| {
|
||||
// 恢复或账号切换后仍归属于真实受理的原 run。
|
||||
context.route = data.ledger.analytics_run.as_ref()?.context.route.clone();
|
||||
Some((context, writer))
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn analytics_capture(
|
||||
&self,
|
||||
) -> Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)> {
|
||||
self.data.try_lock().ok()?.analytics_capture.clone()
|
||||
}
|
||||
|
||||
pub(super) fn record_analytics_revision(
|
||||
&self,
|
||||
revision: u64,
|
||||
change_kind: crate::analytics::contract::ChangeKind,
|
||||
files_changed_count: u64,
|
||||
) {
|
||||
use crate::analytics::contract::{RevisionCreated, RevisionSource, Source};
|
||||
if files_changed_count == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(mut data) = self.data.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if data.ledger.analytics_run.is_none() {
|
||||
return;
|
||||
}
|
||||
data.analytics_output_revision =
|
||||
Some(data.analytics_output_revision.unwrap_or(0).max(revision));
|
||||
let capture = data.analytics_capture.clone();
|
||||
let project_id = data.ledger.project_id.clone();
|
||||
drop(data);
|
||||
crate::analytics::project::revision(
|
||||
capture,
|
||||
&project_id,
|
||||
Source::Direct,
|
||||
RevisionCreated {
|
||||
revision_id: revision.to_string(),
|
||||
revision_source: RevisionSource::Agent,
|
||||
change_kind,
|
||||
files_changed_count: Some(files_changed_count),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn analytics_output_revision(&self) -> Option<String> {
|
||||
self.data
|
||||
.try_lock()
|
||||
.ok()?
|
||||
.analytics_output_revision
|
||||
.map(|revision| revision.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
|
||||
let data = self.lock()?;
|
||||
let mut state = data.ledger.clone();
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
use super::*;
|
||||
|
||||
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
|
||||
use crate::analytics::contract::{Context, Route, RunSource, Source};
|
||||
crate::analytics::run::Metadata::new(
|
||||
Context {
|
||||
route: Route::from_identity(Some(user.into()), Some("https://example.com")),
|
||||
editor_session_id: uuid::Uuid::new_v4().to_string(),
|
||||
client_version: "1.0.0".into(),
|
||||
},
|
||||
Source::Direct,
|
||||
RunSource::UserSubmit,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap();
|
||||
let original = analytics_metadata("A");
|
||||
let host = temp.path().join("host");
|
||||
let session = open_with_analytics_at(
|
||||
&host,
|
||||
&root,
|
||||
"turn",
|
||||
&hash(b"request"),
|
||||
false,
|
||||
&Default::default(),
|
||||
Some(original.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.newly_accepted);
|
||||
assert_eq!(
|
||||
session.snapshot().unwrap().analytics_run,
|
||||
Some(original.clone())
|
||||
);
|
||||
drop(session);
|
||||
let replay = open_with_analytics_at(
|
||||
&host,
|
||||
&root,
|
||||
"turn",
|
||||
&hash(b"request"),
|
||||
false,
|
||||
&Default::default(),
|
||||
Some(analytics_metadata("B")),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!replay.newly_accepted);
|
||||
assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
|
||||
let (temp, session) = fixture(Default::default());
|
||||
let root = session.root.clone();
|
||||
assert!(session.snapshot().unwrap().analytics_run.is_none());
|
||||
drop(session);
|
||||
let replay = open_with_analytics_at(
|
||||
&temp.path().join("host"),
|
||||
&root,
|
||||
"turn-test",
|
||||
&hash(b"request"),
|
||||
false,
|
||||
&Default::default(),
|
||||
Some(analytics_metadata("B")),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.snapshot().unwrap().analytics_run.is_none());
|
||||
}
|
||||
|
||||
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
@@ -13,6 +82,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
|
||||
&config,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.newly_accepted);
|
||||
session
|
||||
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
|
||||
.unwrap();
|
||||
@@ -159,6 +229,7 @@ fn reopened_budget_and_deadline_cannot_be_increased_by_configuration() {
|
||||
)
|
||||
.unwrap();
|
||||
let state = reopened.snapshot().unwrap();
|
||||
assert!(!reopened.newly_accepted);
|
||||
assert_eq!(state.delivery_reviews, 1);
|
||||
assert_eq!(
|
||||
(
|
||||
@@ -278,6 +349,7 @@ fn legacy_budget_is_inherited_without_trusting_project_success_evidence() {
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!session.newly_accepted);
|
||||
assert!(session.admit(EffectKind::Execute, None).is_err());
|
||||
let state = session.snapshot().unwrap();
|
||||
assert_eq!(state.used_passes, 2);
|
||||
|
||||
@@ -229,6 +229,40 @@ fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<Strin
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn analytics_patch_changes(
|
||||
before: &BTreeMap<String, Option<String>>,
|
||||
after: &BTreeMap<String, Option<String>>,
|
||||
) -> Option<(crate::analytics::contract::ChangeKind, u64)> {
|
||||
use crate::analytics::contract::ChangeKind;
|
||||
let mut result = None;
|
||||
for (path, before) in before {
|
||||
let Some((before, after)) = before
|
||||
.as_ref()
|
||||
.zip(after.get(path).and_then(Option::as_ref))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if before == after {
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = crate::analytics::project::file_change_kind(path) else {
|
||||
continue;
|
||||
};
|
||||
result = Some(match result {
|
||||
None => (kind, 1),
|
||||
Some((current, count)) => (
|
||||
if current == kind {
|
||||
current
|
||||
} else {
|
||||
ChangeKind::Mixed
|
||||
},
|
||||
count + 1,
|
||||
),
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn run_transaction(
|
||||
root: &Path,
|
||||
parsed: codex_patch_parser::ApplyPatchArgs,
|
||||
@@ -319,6 +353,13 @@ fn run_transaction(
|
||||
None
|
||||
};
|
||||
lease.finish(passed && !uncertain, changed, None)?;
|
||||
if passed && !uncertain {
|
||||
if let (Some(revision), Some((kind, count))) =
|
||||
(revision, analytics_patch_changes(&before, &after))
|
||||
{
|
||||
session.record_analytics_revision(revision, kind, count);
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"status": if passed && !uncertain { "completed" } else { "failed" },
|
||||
"changedPaths": if started { changed_paths } else { BTreeSet::new() },
|
||||
@@ -351,6 +392,47 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result<Value, Strin
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn analytics_patch_counts_only_known_changed_outputs() {
|
||||
let fingerprints = |entries: &[(&str, Option<&str>)]| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(path, value)| (path.to_string(), value.map(str::to_string)))
|
||||
.collect()
|
||||
};
|
||||
let before = fingerprints(&[
|
||||
("game/a.js", Some("a")),
|
||||
("game/same.js", Some("same")),
|
||||
("game/unknown.js", None),
|
||||
("game/unreadable.js", Some("old")),
|
||||
(".agent/state.json", Some("old")),
|
||||
]);
|
||||
let after = fingerprints(&[
|
||||
("game/a.js", Some("b")),
|
||||
("game/same.js", Some("same")),
|
||||
("game/unknown.js", Some("new")),
|
||||
("game/unreadable.js", None),
|
||||
(".agent/state.json", Some("new")),
|
||||
]);
|
||||
assert_eq!(
|
||||
analytics_patch_changes(&before, &after),
|
||||
Some((crate::analytics::contract::ChangeKind::Code, 1))
|
||||
);
|
||||
assert_eq!(analytics_patch_changes(&before, &before), None);
|
||||
let before = fingerprints(&[
|
||||
("game/a.js", Some("missing")),
|
||||
("assets/a.png", Some("old")),
|
||||
]);
|
||||
let after = fingerprints(&[
|
||||
("game/a.js", Some("new")),
|
||||
("assets/a.png", Some("missing")),
|
||||
]);
|
||||
assert_eq!(
|
||||
analytics_patch_changes(&before, &after),
|
||||
Some((crate::analytics::contract::ChangeKind::Mixed, 2))
|
||||
);
|
||||
}
|
||||
|
||||
fn project() -> (tempfile::TempDir, PathBuf) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
@@ -415,15 +497,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() {
|
||||
let (temp, root) = project();
|
||||
let session = direct_execution::open_at(
|
||||
let config = temp.path().join("analytics-config");
|
||||
let (metadata, context, writer) =
|
||||
super::super::direct_tool_bridge::analytics_test_writer(&config);
|
||||
let session = direct_execution::open_with_analytics_at(
|
||||
&temp.path().join("host"),
|
||||
&root,
|
||||
"patch-roundtrip",
|
||||
&format!("{:x}", Sha256::digest(b"request")),
|
||||
false,
|
||||
&direct_validation::DirectValidationConfig::default(),
|
||||
Some(metadata),
|
||||
)
|
||||
.unwrap();
|
||||
session.set_analytics_capture(Some((context.clone(), writer.clone())));
|
||||
session
|
||||
.freeze_contract(json!({"fixture":"patch protocol only"}))
|
||||
.unwrap();
|
||||
@@ -499,6 +586,29 @@ mod tests {
|
||||
"writes do not invent execution passes"
|
||||
);
|
||||
assert!(session.snapshot().unwrap().active.is_empty());
|
||||
assert_eq!(
|
||||
session.analytics_output_revision(),
|
||||
None,
|
||||
"text fixture files are not classified as成果"
|
||||
);
|
||||
let outputs = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/result.js\n+const result = 1;\n*** Add File: game/style.css\n+body { color: red; }\n*** End Patch"})).await.unwrap();
|
||||
assert_eq!(outputs["status"], "completed", "{outputs}");
|
||||
let revision = outputs["revision"].as_u64().unwrap().to_string();
|
||||
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
|
||||
let partial_output = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/partial.js\n+const partial = 1;\n*** Update File: game/absent.js\n@@\n-old\n+new\n*** End Patch"})).await.unwrap();
|
||||
assert_eq!(partial_output["status"], "failed");
|
||||
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
|
||||
let events = super::super::direct_tool_bridge::drain_analytics_test_writer(
|
||||
&config, &context, &writer,
|
||||
);
|
||||
let revisions: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event["event_name"] == "project_revision_created")
|
||||
.collect();
|
||||
assert_eq!(revisions.len(), 1);
|
||||
assert_eq!(revisions[0]["user_id"], "A");
|
||||
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
|
||||
assert_eq!(revisions[0]["properties"]["files_changed_count"], 2);
|
||||
session.interrupt("fixture stopped".into()).unwrap();
|
||||
assert!(apply(
|
||||
&root,
|
||||
|
||||
@@ -4243,7 +4243,44 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
|
||||
advisory_interaction: bool,
|
||||
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Result<BrowserValidationResult, String> {
|
||||
run_direct_browser_evidence_with_analytics_at(
|
||||
root,
|
||||
evidence_root,
|
||||
scenario,
|
||||
advisory_interaction,
|
||||
cancellation,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
|
||||
root: &Path,
|
||||
evidence_root: PathBuf,
|
||||
scenario: Option<crate::browser::BrowserPlaytestScenario>,
|
||||
advisory_interaction: bool,
|
||||
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
) -> Result<BrowserValidationResult, String> {
|
||||
let observation = crate::analytics::preview::prepare(
|
||||
root,
|
||||
capture,
|
||||
crate::analytics::contract::Source::Direct,
|
||||
crate::analytics::contract::PreviewSource::Agent,
|
||||
);
|
||||
let (_analytics_lease, observation) = match observation {
|
||||
Some((lease, observation)) => (Some(lease), Some(observation)),
|
||||
None => (None, None),
|
||||
};
|
||||
let (preview, stop_sender) = start_local_game_preview_for_project(root)?;
|
||||
if let Some(observation) = observation {
|
||||
observation
|
||||
.with_cancellation(cancellation.clone())
|
||||
.schedule(preview.port);
|
||||
}
|
||||
let validation = crate::browser::validate_local_preview_in_browser_with_cancellation(
|
||||
BrowserValidationInput {
|
||||
url: preview.url,
|
||||
@@ -4261,6 +4298,7 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
|
||||
cancellation,
|
||||
)
|
||||
.await;
|
||||
drop(_analytics_lease);
|
||||
let _ = stop_sender.send(());
|
||||
validation
|
||||
}
|
||||
@@ -4781,6 +4819,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4792,6 +4832,11 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
analytics_attempt_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if !root.is_absolute() || !root.is_dir() {
|
||||
return Err("当前项目目录不存在或不是绝对路径".to_string());
|
||||
@@ -4814,6 +4859,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
turn_emitter,
|
||||
audit,
|
||||
direct_user_item,
|
||||
capture,
|
||||
analytics_attempt_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -5014,6 +5061,11 @@ async fn run_direct_game_creator_turn_inner(
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
analytics_attempt_id: Option<&str>,
|
||||
) -> Result<String, DirectCodexTurnFailure> {
|
||||
let requires_contract = super::direct_delivery::requires_new_web_contract(
|
||||
root,
|
||||
@@ -5035,16 +5087,49 @@ async fn run_direct_game_creator_turn_inner(
|
||||
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
|
||||
})?
|
||||
.validation;
|
||||
let execution_guard =
|
||||
super::direct_execution::begin(root, prompt, requires_contract, execution_config)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
|
||||
})?;
|
||||
let analytics_run = capture.as_ref().map(|(context, _)| {
|
||||
crate::analytics::run::Metadata::new(
|
||||
context.clone(),
|
||||
crate::analytics::contract::Source::Direct,
|
||||
crate::analytics::contract::RunSource::UserSubmit,
|
||||
)
|
||||
});
|
||||
let execution_guard = super::direct_execution::begin(
|
||||
root,
|
||||
prompt,
|
||||
requires_contract,
|
||||
execution_config,
|
||||
analytics_run,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
|
||||
let execution_session = execution_guard.session();
|
||||
execution_session.set_analytics_capture(capture.clone());
|
||||
let started = execution_session
|
||||
.newly_accepted
|
||||
.then(std::time::Instant::now);
|
||||
if execution_session.newly_accepted {
|
||||
if let Ok(ledger) = execution_session.snapshot() {
|
||||
if let (Some((_, writer)), Some(metadata)) = (&capture, &ledger.analytics_run) {
|
||||
crate::analytics::run::accepted(writer, root, &ledger.project_id, metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
|
||||
let result: Result<String, DirectCodexTurnFailure> = async {
|
||||
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
|
||||
return Ok(report);
|
||||
}
|
||||
if execution_session.newly_accepted {
|
||||
if let Ok(ledger) = execution_session.snapshot() {
|
||||
crate::analytics::goal::accepted(
|
||||
capture.clone(),
|
||||
root,
|
||||
&ledger.project_id,
|
||||
crate::analytics::contract::Source::Direct,
|
||||
);
|
||||
}
|
||||
}
|
||||
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit("running", Some("preparing"), None, None);
|
||||
@@ -5334,6 +5419,118 @@ async fn run_direct_game_creator_turn_inner(
|
||||
})?;
|
||||
}
|
||||
Ok(visible_reply)
|
||||
}.await;
|
||||
if let Ok(ledger) = execution_session.snapshot() {
|
||||
if let (Some(metadata), Some((end_reason, error_code))) = (
|
||||
&ledger.analytics_run,
|
||||
direct_analytics_outcome(
|
||||
ledger.phase,
|
||||
ledger.requires_contract || ledger.contract.is_some(),
|
||||
result.is_err(),
|
||||
execution_session.was_aborted(),
|
||||
),
|
||||
) {
|
||||
let output_revision = execution_session.analytics_output_revision();
|
||||
crate::analytics::run::direct_finished(
|
||||
capture,
|
||||
root,
|
||||
&ledger.project_id,
|
||||
metadata,
|
||||
analytics_attempt_id,
|
||||
crate::analytics::run::Outcome {
|
||||
turn_id: Some(ledger.client_turn_id.clone()),
|
||||
end_reason,
|
||||
error_code,
|
||||
duration_ms: started
|
||||
.and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()),
|
||||
output_change_detected: output_revision.as_ref().map(|_| true),
|
||||
revision_id: output_revision,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn direct_analytics_outcome(
|
||||
phase: super::direct_execution::ExecutionPhase,
|
||||
has_contract: bool,
|
||||
failed: bool,
|
||||
aborted: bool,
|
||||
) -> Option<(
|
||||
crate::analytics::contract::RunEndReason,
|
||||
Option<crate::analytics::contract::ErrorCode>,
|
||||
)> {
|
||||
use super::direct_execution::ExecutionPhase;
|
||||
use crate::analytics::contract::{ErrorCode, RunEndReason};
|
||||
if phase == ExecutionPhase::Interrupted || aborted {
|
||||
return None;
|
||||
}
|
||||
if phase == ExecutionPhase::Exhausted {
|
||||
return Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)));
|
||||
}
|
||||
if failed {
|
||||
// Direct 当前只保留 stage 和展示错误字符串,不从正文猜 Provider 错误类别。
|
||||
return Some((
|
||||
RunEndReason::Failed,
|
||||
Some(ErrorCode::RuntimeErrorUnclassified),
|
||||
));
|
||||
}
|
||||
if phase == ExecutionPhase::Completed || !has_contract {
|
||||
return Some((RunEndReason::Finished, None));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod direct_analytics_tests {
|
||||
use super::*;
|
||||
use crate::agent::direct_execution::ExecutionPhase;
|
||||
use crate::analytics::contract::{ErrorCode, RunEndReason};
|
||||
|
||||
#[test]
|
||||
fn terminal_reports_do_not_turn_exhaustion_or_cancellation_into_success() {
|
||||
assert_eq!(
|
||||
direct_analytics_outcome(ExecutionPhase::Exhausted, true, false, false),
|
||||
Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)))
|
||||
);
|
||||
for failed in [true, false] {
|
||||
assert_eq!(
|
||||
direct_analytics_outcome(ExecutionPhase::Interrupted, true, failed, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
direct_analytics_outcome(ExecutionPhase::Working, false, failed, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_delivery_does_not_hide_later_projection_failure() {
|
||||
assert_eq!(
|
||||
direct_analytics_outcome(ExecutionPhase::Completed, true, true, false),
|
||||
Some((
|
||||
RunEndReason::Failed,
|
||||
Some(ErrorCode::RuntimeErrorUnclassified)
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
direct_analytics_outcome(ExecutionPhase::Completed, true, false, false),
|
||||
Some((RunEndReason::Finished, None))
|
||||
);
|
||||
assert_eq!(
|
||||
direct_analytics_outcome(ExecutionPhase::Working, false, false, false),
|
||||
Some((RunEndReason::Finished, None))
|
||||
);
|
||||
for phase in [
|
||||
ExecutionPhase::Working,
|
||||
ExecutionPhase::Draining,
|
||||
ExecutionPhase::Sealing,
|
||||
] {
|
||||
assert_eq!(direct_analytics_outcome(phase, true, false, false), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -34,8 +34,10 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
mut user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
analytics_attempt_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
let capture = crate::analytics::gui::capture_writer_context();
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
|
||||
@@ -99,6 +101,8 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
canonical_user_item,
|
||||
capture,
|
||||
analytics_attempt_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1600,6 +1600,15 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
bridge_write_file_with_permit(root, arguments, None)
|
||||
}
|
||||
|
||||
fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option<bool> {
|
||||
crate::analytics::project::file_content_changed(
|
||||
root,
|
||||
path,
|
||||
content,
|
||||
DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES as u64,
|
||||
)
|
||||
}
|
||||
|
||||
fn bridge_write_file_with_permit(
|
||||
root: &Path,
|
||||
arguments: &Value,
|
||||
@@ -1641,6 +1650,11 @@ fn bridge_write_file_with_permit(
|
||||
"direct-codex.file.write",
|
||||
)?;
|
||||
let lock_wait_ms = acquire_started.elapsed().as_millis();
|
||||
let analytics_change_kind = write_permit.and_then(|_| {
|
||||
let kind = crate::analytics::project::file_change_kind(&path)?;
|
||||
(bridge_file_content_changed(root, &path, content.as_bytes()) == Some(true))
|
||||
.then_some(kind)
|
||||
});
|
||||
let write_started = std::time::Instant::now();
|
||||
let commit = || {
|
||||
let written = write_local_project_file_at(root, &path, content)?;
|
||||
@@ -1653,6 +1667,9 @@ fn bridge_write_file_with_permit(
|
||||
Some(permit) => permit.run(commit)?,
|
||||
None => commit()?,
|
||||
};
|
||||
if let (Some(permit), Some(kind)) = (write_permit, analytics_change_kind) {
|
||||
permit.record_analytics_revision(revision, kind, 1);
|
||||
}
|
||||
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
|
||||
if lock_wait_ms + write_ms > 200 {
|
||||
app_log!(
|
||||
@@ -3407,7 +3424,7 @@ async fn start_tool_bridge_for_source(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::agent) struct DirectExecutionTestFixture {
|
||||
pub(crate) struct DirectExecutionTestFixture {
|
||||
execution: Option<super::direct_execution::ExecutionSessionGuard>,
|
||||
_host: tempfile::TempDir,
|
||||
}
|
||||
@@ -3426,7 +3443,7 @@ impl Drop for DirectExecutionTestFixture {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::agent) async fn direct_execution_fixture(
|
||||
pub(crate) async fn direct_execution_fixture(
|
||||
root: &Path,
|
||||
turn: &str,
|
||||
) -> DirectExecutionTestFixture {
|
||||
@@ -3473,6 +3490,82 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test(
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn analytics_test_writer(
|
||||
config: &Path,
|
||||
) -> (
|
||||
crate::analytics::run::Metadata,
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
) {
|
||||
use crate::analytics::{
|
||||
contract::{Context, Route, RunSource, Source},
|
||||
run,
|
||||
store::AnalyticsWriter,
|
||||
};
|
||||
let mut context = Context {
|
||||
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
|
||||
editor_session_id: uuid::Uuid::new_v4().to_string(),
|
||||
client_version: "1.0.0".into(),
|
||||
};
|
||||
fs::create_dir_all(config).expect("create analytics test config directory");
|
||||
let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit);
|
||||
context.route.user_id = Some("B".into());
|
||||
let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone());
|
||||
(metadata, context, writer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn drain_analytics_test_writer(
|
||||
config: &Path,
|
||||
context: &crate::analytics::contract::Context,
|
||||
writer: &crate::analytics::store::AnalyticsWriter,
|
||||
) -> Vec<Value> {
|
||||
use crate::analytics::contract::{EntrySource, EventData, SessionStart, Source};
|
||||
let marker = context
|
||||
.capture(
|
||||
EventData::EditorSessionStart(SessionStart {
|
||||
entry_source: EntrySource::DirectLaunch,
|
||||
first_project_id: None,
|
||||
}),
|
||||
None,
|
||||
Source::Editor,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let marker_id = marker.event_id.clone();
|
||||
assert!(writer.try_record(context.route.clone(), marker, marker_id.clone()));
|
||||
assert!(writer.flush());
|
||||
let batches = config
|
||||
.join("analytics/instances")
|
||||
.join(&context.editor_session_id)
|
||||
.join("batches");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let events: Vec<Value> = fs::read_dir(&batches)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
|
||||
.filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok())
|
||||
.flat_map(|contents| {
|
||||
contents
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
if events.iter().any(|event| event["event_id"] == marker_id) {
|
||||
return events;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"analytics FIFO sentinel timed out"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[tokio::test]
|
||||
@@ -4262,6 +4355,293 @@ mod tests {
|
||||
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
|
||||
use crate::analytics::{
|
||||
contract::{ErrorCode, RunEndReason},
|
||||
run,
|
||||
};
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path().join("project");
|
||||
let config = temporary.path().join("config");
|
||||
let (metadata, context, writer) = analytics_test_writer(&config);
|
||||
let original_capture = Some((metadata.context.clone(), writer.clone()));
|
||||
let mut lifecycle = crate::analytics::gui::LifecycleFixture::start(
|
||||
metadata.context.clone(),
|
||||
writer.clone(),
|
||||
);
|
||||
lifecycle.create_and_open(&root, "direct-analytics");
|
||||
let session = super::super::direct_execution::open_with_analytics_at(
|
||||
&temporary.path().join("host"),
|
||||
&root,
|
||||
"analytics-write",
|
||||
&format!("{:x}", Sha256::digest(b"request")),
|
||||
false,
|
||||
&Default::default(),
|
||||
Some(metadata.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
session.set_analytics_capture(Some((context.clone(), writer.clone())));
|
||||
super::super::direct_delivery::register_contract(
|
||||
&root,
|
||||
&session,
|
||||
&json!({
|
||||
"scope": "核对当前项目宿主写入的成果采集",
|
||||
"changeKind": "project",
|
||||
"requirements": [{"id": "analytics-output", "kind": "artifact", "path": "game/index.html"}]
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("freeze a validated delivery contract before writing");
|
||||
let project_id = session.snapshot().unwrap().project_id;
|
||||
run::accepted(&writer, &root, &project_id, &metadata);
|
||||
crate::analytics::goal::accepted(
|
||||
original_capture.clone(),
|
||||
&root,
|
||||
&project_id,
|
||||
crate::analytics::contract::Source::Direct,
|
||||
);
|
||||
let lease = session
|
||||
.admit(super::super::direct_execution::EffectKind::Write, None)
|
||||
.unwrap();
|
||||
let permit = lease.write_permit().unwrap();
|
||||
let arguments = json!({"path":"game/index.html", "content":"<!doctype html><html><body>真实预览</body></html>"});
|
||||
let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit));
|
||||
assert_eq!(changed["isError"], false);
|
||||
let payload: Value =
|
||||
serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
let revision = payload["revision"].as_u64().unwrap().to_string();
|
||||
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
|
||||
assert_eq!(
|
||||
bridge_write_file_with_permit(&root, &arguments, Some(&permit))["isError"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
bridge_write_file_with_permit(
|
||||
&root,
|
||||
&json!({"path":"../bad.js","content":"bad"}),
|
||||
Some(&permit)
|
||||
)["isError"],
|
||||
true
|
||||
);
|
||||
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
|
||||
lease.finish(true, true, None).unwrap();
|
||||
let attempt = uuid::Uuid::new_v4().to_string();
|
||||
run::direct_finished(
|
||||
Some((context.clone(), writer.clone())),
|
||||
&root,
|
||||
&project_id,
|
||||
&metadata,
|
||||
Some(&attempt),
|
||||
run::Outcome {
|
||||
turn_id: Some("analytics-write".into()),
|
||||
end_reason: RunEndReason::Failed,
|
||||
error_code: Some(ErrorCode::RuntimeErrorUnclassified),
|
||||
duration_ms: None,
|
||||
output_change_detected: Some(true),
|
||||
revision_id: session.analytics_output_revision(),
|
||||
},
|
||||
);
|
||||
run::settle(Some((context.clone(), writer.clone())), &attempt, false);
|
||||
// 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。
|
||||
let served_root = crate::project_game_root(&root);
|
||||
fs::create_dir_all(&served_root).unwrap();
|
||||
fs::write(
|
||||
served_root.join("index.html"),
|
||||
"<!doctype html><html><body>真实预览构建</body></html>",
|
||||
)
|
||||
.unwrap();
|
||||
let (preview_lease, observation) = crate::analytics::preview::prepare(
|
||||
&root,
|
||||
original_capture.clone(),
|
||||
crate::analytics::contract::Source::Editor,
|
||||
crate::analytics::contract::PreviewSource::User,
|
||||
)
|
||||
.unwrap();
|
||||
let (preview, stop) = crate::start_local_game_preview_for_project(&root).unwrap();
|
||||
observation.observe(preview.port).await;
|
||||
let preview_revision = crate::read_game_creator_agent_runtime_project_revision(&root)
|
||||
.unwrap()
|
||||
.revision
|
||||
.to_string();
|
||||
drop(preview_lease);
|
||||
let _ = stop.send(());
|
||||
let checkpoint = crate::commands::checkpoint_with_capture_for_test(
|
||||
root.to_string_lossy().into_owned(),
|
||||
original_capture,
|
||||
)
|
||||
.unwrap();
|
||||
lifecycle.exit();
|
||||
let events = drain_analytics_test_writer(&config, &context, &writer);
|
||||
let ids: std::collections::HashSet<_> = events
|
||||
.iter()
|
||||
.map(|event| event["event_id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids.len(), events.len());
|
||||
let chain: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event["user_id"] == "A")
|
||||
.collect();
|
||||
for name in [
|
||||
"editor_session_start",
|
||||
"editor_focus_start",
|
||||
"project_create_success",
|
||||
"project_open",
|
||||
"creative_task_submit",
|
||||
"project_revision_created",
|
||||
"agent_run_failed",
|
||||
"preview_ready",
|
||||
"project_save",
|
||||
"editor_focus_end",
|
||||
"editor_session_end",
|
||||
] {
|
||||
assert_eq!(
|
||||
chain
|
||||
.iter()
|
||||
.filter(|event| event["event_name"] == name)
|
||||
.count(),
|
||||
1,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
for event in &chain {
|
||||
assert_eq!(
|
||||
event["editor_session_id"],
|
||||
metadata.context.editor_session_id
|
||||
);
|
||||
if !event["project_id"].is_null() {
|
||||
assert_eq!(event["project_id"], project_id);
|
||||
}
|
||||
if !event["creative_task_id"].is_null() {
|
||||
assert_eq!(event["creative_task_id"], project_id);
|
||||
}
|
||||
if !event["agent_run_id"].is_null() {
|
||||
assert_eq!(event["agent_run_id"], metadata.run_id);
|
||||
}
|
||||
let name = event["event_name"].as_str().unwrap();
|
||||
if matches!(
|
||||
name,
|
||||
"project_create_success"
|
||||
| "project_open"
|
||||
| "creative_task_submit"
|
||||
| "project_revision_created"
|
||||
| "agent_run_failed"
|
||||
| "preview_ready"
|
||||
| "project_save"
|
||||
) {
|
||||
assert_eq!(
|
||||
event["project_id"], project_id,
|
||||
"{name} must identify its project"
|
||||
);
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"creative_task_submit"
|
||||
| "project_revision_created"
|
||||
| "agent_run_failed"
|
||||
| "preview_ready"
|
||||
| "project_save"
|
||||
) {
|
||||
assert_eq!(
|
||||
event["creative_task_id"], project_id,
|
||||
"{name} must identify its goal"
|
||||
);
|
||||
}
|
||||
if name == "agent_run_failed" {
|
||||
assert_eq!(event["agent_run_id"], metadata.run_id);
|
||||
assert_eq!(event["agent_turn_id"], "analytics-write");
|
||||
}
|
||||
}
|
||||
let save = chain
|
||||
.iter()
|
||||
.find(|event| event["event_name"] == "project_save")
|
||||
.unwrap();
|
||||
assert_eq!(save["properties"]["save_source"], "checkpoint");
|
||||
assert!(Path::new(&checkpoint.checkpoint_path).is_dir());
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(serde_json::to_vec(&metadata.context.route).unwrap());
|
||||
digest.update([0]);
|
||||
digest.update(format!("{project_id}:{}:project_save", checkpoint.checkpoint_id).as_bytes());
|
||||
let checkpoint_fact = format!("{:x}", digest.finalize());
|
||||
let batches = config
|
||||
.join("analytics/instances")
|
||||
.join(&context.editor_session_id)
|
||||
.join("batches");
|
||||
let checkpoint_fact_matches = fs::read_dir(batches)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter_map(|entry| fs::read(entry.path().join("meta.json")).ok())
|
||||
.filter_map(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
||||
.filter(|batch| batch["facts"][&checkpoint_fact] == save["event_id"])
|
||||
.count();
|
||||
assert_eq!(
|
||||
checkpoint_fact_matches, 1,
|
||||
"save fact must use the actual checkpoint ID"
|
||||
);
|
||||
let ready = chain
|
||||
.iter()
|
||||
.find(|event| event["event_name"] == "preview_ready")
|
||||
.unwrap();
|
||||
assert_eq!(ready["properties"]["preview_version"], preview_revision);
|
||||
let focus_start = chain
|
||||
.iter()
|
||||
.find(|event| event["event_name"] == "editor_focus_start")
|
||||
.unwrap();
|
||||
let focus_end = chain
|
||||
.iter()
|
||||
.find(|event| event["event_name"] == "editor_focus_end")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
focus_start["properties"]["focus_interval_id"],
|
||||
focus_end["properties"]["focus_interval_id"]
|
||||
);
|
||||
let revisions: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event["event_name"] == "project_revision_created")
|
||||
.collect();
|
||||
assert_eq!(revisions.len(), 1);
|
||||
assert_eq!(revisions[0]["user_id"], "A");
|
||||
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
|
||||
let failed: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event["event_name"] == "agent_run_failed")
|
||||
.collect();
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0]["user_id"], "A");
|
||||
assert_eq!(failed[0]["properties"]["revision_id"], revision);
|
||||
assert_eq!(failed[0]["properties"]["output_change_detected"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytics_file_comparison_requires_known_bounded_content() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
assert_eq!(
|
||||
bridge_file_content_changed(root, "new.js", b"new"),
|
||||
Some(true)
|
||||
);
|
||||
fs::write(root.join("new.js"), b"new").unwrap();
|
||||
assert_eq!(
|
||||
bridge_file_content_changed(root, "new.js", b"new"),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
bridge_file_content_changed(root, "new.js", b"changed"),
|
||||
Some(true)
|
||||
);
|
||||
fs::create_dir(root.join("directory.js")).unwrap();
|
||||
assert_eq!(
|
||||
bridge_file_content_changed(root, "directory.js", b"new"),
|
||||
None
|
||||
);
|
||||
fs::write(
|
||||
root.join("large.js"),
|
||||
vec![0; DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + 1],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bridge_file_content_changed(root, "large.js", b"new"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() {
|
||||
let temporary = tempfile::tempdir().expect("create direct write root");
|
||||
|
||||
@@ -453,12 +453,15 @@ async fn run_browser_with_budget(
|
||||
sequence,
|
||||
),
|
||||
)?;
|
||||
let evidence = super::direct_runtime::run_direct_browser_evidence_with_cancellation_at(
|
||||
let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at(
|
||||
root,
|
||||
evidence_root,
|
||||
scenario,
|
||||
false,
|
||||
reservation.as_ref().map(|r| r.session.cancel_flag()),
|
||||
reservation
|
||||
.as_ref()
|
||||
.and_then(|r| r.session.analytics_capture()),
|
||||
)
|
||||
.await;
|
||||
let (result, passed) = match evidence {
|
||||
|
||||
@@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{
|
||||
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
||||
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
|
||||
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
||||
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
validate_platform_art_icon_prompt, PlatformArtAssetGenerationOptions,
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use draft_validation::{
|
||||
|
||||
@@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options(
|
||||
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
|
||||
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
|
||||
if snapshot.generation_kind == generation_kind
|
||||
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options)
|
||||
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options)
|
||||
&& options.asset_kind == kind
|
||||
&& options.aspect_ratio == ratio
|
||||
&& options.image_size == size
|
||||
@@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at(
|
||||
Ok(PlatformArtGenerationReferences { canonical, ordered })
|
||||
}
|
||||
|
||||
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
|
||||
// External Editor validates each description independently (currently at
|
||||
// 200 Unicode characters). Keep the gameplay context short enough that a
|
||||
// long creation request cannot reject the atlas before it is queued.
|
||||
const MAX_DESCRIPTION_CHARS: usize = 200;
|
||||
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
|
||||
let category =
|
||||
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
|
||||
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
||||
category
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
||||
);
|
||||
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
||||
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
|
||||
}
|
||||
|
||||
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let actual_chars = normalized.chars().count();
|
||||
if actual_chars <= max_chars {
|
||||
return normalized;
|
||||
pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> {
|
||||
// 与 External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err("图标素材描述不能为空".to_string());
|
||||
}
|
||||
if max_chars <= 3 {
|
||||
return ".".repeat(max_chars);
|
||||
if prompt.chars().count() > 200 {
|
||||
return Err("图标素材描述不能超过 200 个字符".to_string());
|
||||
}
|
||||
let mut output = normalized
|
||||
.chars()
|
||||
.take(max_chars.saturating_sub(3))
|
||||
.collect::<String>();
|
||||
output.push_str("...");
|
||||
output
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_platform_art_image_with_limits(
|
||||
@@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration {
|
||||
pub(in crate::agent) fn admit_platform_art_generation_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
briefs: &[AgentGroupBrief],
|
||||
_briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
) -> Result<AdmittedPlatformArtGeneration, String> {
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard(
|
||||
@@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at(
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
admission: AdmittedPlatformArtGeneration,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
if admission.context != context
|
||||
@@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at(
|
||||
briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
generate_platform_art_asset_with_runtime_options_at(
|
||||
@@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
||||
if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
|
||||
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
|
||||
}
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||
generate_platform_art_asset_with_runtime_options_at(
|
||||
@@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
runtime_context: Option<&PlatformArtGenerationRuntimeContext>,
|
||||
) -> Result<PreparedPlatformArtAssetGeneration, String> {
|
||||
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
validate_platform_art_icon_prompt(prompt)?;
|
||||
}
|
||||
enforce_project_permission_policy(root, "canvas.asset_generate")?;
|
||||
let persisted_runtime_state = runtime_context
|
||||
.map(|context| {
|
||||
@@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
.timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?;
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||
let (
|
||||
generated,
|
||||
canvas_context,
|
||||
@@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
serde_json::json!({
|
||||
"referenceId": reference_id,
|
||||
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
|
||||
"iconDescriptions": [generation_prompt],
|
||||
"sliceCount": options.slice_count,
|
||||
"sliceMode": options.slice_mode,
|
||||
"gridX": options.grid_x,
|
||||
@@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec(
|
||||
|
||||
pub(crate) fn build_platform_art_asset_prompt(
|
||||
prompt: &str,
|
||||
briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
) -> String {
|
||||
if options.asset_kind == GameCreationAppAssetKind::Image {
|
||||
@@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt(
|
||||
truncate_prompt_context(prompt.trim())
|
||||
);
|
||||
}
|
||||
let art_asset_brief = briefs
|
||||
.iter()
|
||||
.flat_map(|brief| brief.role_briefs.iter())
|
||||
.find(|role_brief| {
|
||||
role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset"
|
||||
})
|
||||
.map(|role_brief| role_brief.markdown.trim())
|
||||
.filter(|markdown| !markdown.is_empty())
|
||||
.unwrap_or(prompt_text!("media.default_art_brief"));
|
||||
format!(
|
||||
prompt_text!("media.spritesheet_generation"),
|
||||
truncate_inline(prompt, 240),
|
||||
truncate_prompt_context(art_asset_brief)
|
||||
)
|
||||
// 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。
|
||||
prompt.trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -8533,15 +8500,15 @@ mod canvas_generation_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
|
||||
// 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同,
|
||||
// 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。
|
||||
fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() {
|
||||
// 图集描述由用户提供,工程化生图提示词由 API Server 添加。
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
assert!(
|
||||
build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集")
|
||||
assert_eq!(
|
||||
build_platform_art_asset_prompt("原创收集玩法", &options),
|
||||
"原创收集玩法"
|
||||
);
|
||||
assert_eq!(
|
||||
platform_art_asset_art_spec(&options)["assetType"],
|
||||
@@ -8945,7 +8912,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "失败后可重试素材".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options);
|
||||
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options);
|
||||
let runtime_context =
|
||||
standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false)
|
||||
.expect("standalone retry context");
|
||||
@@ -9121,8 +9088,8 @@ mod canvas_generation_tests {
|
||||
asset_label: "图标规范".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options);
|
||||
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options);
|
||||
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options);
|
||||
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options);
|
||||
let first =
|
||||
standalone_platform_art_generation_runtime_context(&first_prompt, &options, false)
|
||||
.expect("first standalone slot context");
|
||||
@@ -9556,7 +9523,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "图标规范".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options);
|
||||
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options);
|
||||
let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false)
|
||||
.expect("current standalone context");
|
||||
let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false)
|
||||
@@ -9676,8 +9643,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "另一个图标规范".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let other_prompt =
|
||||
build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options);
|
||||
let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options);
|
||||
let other_context = standalone_platform_art_generation_runtime_context(
|
||||
&other_prompt,
|
||||
&other_options,
|
||||
@@ -10877,7 +10843,7 @@ mod canvas_generation_tests {
|
||||
let current_prompt = "恢复时使用同一个生成意图";
|
||||
let current_options = PlatformArtAssetGenerationOptions::default();
|
||||
let durable_generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "持久化且必须原样重发的生成正文",
|
||||
"kind": "spec",
|
||||
@@ -11143,7 +11109,7 @@ mod canvas_generation_tests {
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "生成同一套整包美术";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": generation_prompt,
|
||||
"kind": "spec",
|
||||
@@ -11530,8 +11496,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "账号隔离测试".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": generation_prompt,
|
||||
"kind": "spec",
|
||||
@@ -11739,7 +11704,7 @@ mod canvas_generation_tests {
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let durable_generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "持久化的原始生成正文",
|
||||
"kind": "spec",
|
||||
@@ -11883,7 +11848,7 @@ mod canvas_generation_tests {
|
||||
};
|
||||
let options = PlatformArtAssetGenerationOptions::default();
|
||||
let old_generation_prompt =
|
||||
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options);
|
||||
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options);
|
||||
let binding_access =
|
||||
ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None)
|
||||
.expect("prepare changed-intent binding access");
|
||||
@@ -12050,7 +12015,7 @@ mod canvas_generation_tests {
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "保持同一个生成提示词";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||
let binding_access =
|
||||
ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None)
|
||||
.expect("prepare changed-reference binding access");
|
||||
@@ -12313,8 +12278,7 @@ mod canvas_generation_tests {
|
||||
.expect("create accepted failure ledger access");
|
||||
let current_prompt = "失败恢复提示词";
|
||||
let current_options = PlatformArtAssetGenerationOptions::default();
|
||||
let generation_prompt =
|
||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||
let (state, _) = prepare_platform_art_generation_runtime_state(
|
||||
root,
|
||||
&runtime_context,
|
||||
@@ -12517,7 +12481,7 @@ mod canvas_generation_tests {
|
||||
screen_color: None,
|
||||
};
|
||||
let prompt = "恢复已受理视觉规范图";
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||
let (_, _, frozen_platform_session) =
|
||||
resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account");
|
||||
let frozen_platform_session =
|
||||
@@ -12831,21 +12795,32 @@ mod canvas_generation_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_art_spritesheet_request_preserves_project_requirements() {
|
||||
let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法");
|
||||
assert_eq!(descriptions.len(), 1);
|
||||
assert!(descriptions[0].contains("原创收集玩法"));
|
||||
assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定"));
|
||||
fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() {
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}";
|
||||
validate_platform_art_icon_prompt(prompt).expect("valid multiline description");
|
||||
assert_eq!(
|
||||
build_platform_art_asset_prompt(prompt, &options),
|
||||
"金币\n木制宝箱\n银色钥匙"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() {
|
||||
let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128));
|
||||
|
||||
assert!(!descriptions.is_empty());
|
||||
assert!(descriptions
|
||||
.iter()
|
||||
.all(|description| description.chars().count() <= 200));
|
||||
fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() {
|
||||
let boundary = "🪙".repeat(200);
|
||||
validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n"))
|
||||
.expect("200 Unicode characters after Rust trim must pass");
|
||||
let error = validate_platform_art_icon_prompt(&"🪙".repeat(201))
|
||||
.expect_err("201 Unicode characters must be rejected instead of truncated");
|
||||
assert!(error.contains("200"), "{error}");
|
||||
for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] {
|
||||
assert!(validate_platform_art_icon_prompt(blank).is_err());
|
||||
}
|
||||
validate_platform_art_icon_prompt("\u{feff}")
|
||||
.expect("BOM is not whitespace under the API Server Rust trim contract");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -126,6 +126,9 @@ pub(crate) struct DesignTurn {
|
||||
pub(crate) attempt: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) model_selection: Option<DesignModelSelection>,
|
||||
/// 仅持久化埋点关联,旧回合缺失时不补历史执行。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) analytics: Option<crate::analytics::run::Metadata>,
|
||||
}
|
||||
|
||||
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
route: Route::from_identity(Some("123".into()), Some("https://example.com/api")),
|
||||
editor_session_id: Uuid::new_v4().to_string(),
|
||||
client_version: "0.1.67".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> RunFinished {
|
||||
RunFinished {
|
||||
agent_type: AgentType::GameAgent,
|
||||
run_source: RunSource::UserSubmit,
|
||||
duration_ms: None,
|
||||
retry_index: 0,
|
||||
output_change_detected: None,
|
||||
revision_id: None,
|
||||
end_reason: RunEndReason::Finished,
|
||||
}
|
||||
}
|
||||
|
||||
fn samples() -> Vec<Event> {
|
||||
let mut failed = run();
|
||||
failed.end_reason = RunEndReason::Failed;
|
||||
let values = vec![
|
||||
(
|
||||
EventData::EditorSessionStart(SessionStart {
|
||||
entry_source: EntrySource::DirectLaunch,
|
||||
first_project_id: None,
|
||||
}),
|
||||
None,
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::EditorSessionEnd(SessionEnd {
|
||||
end_reason: SessionEndReason::UserExit,
|
||||
session_duration_ms: Some(10),
|
||||
last_project_id: None,
|
||||
}),
|
||||
None,
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::EditorFocusStart(FocusStart {
|
||||
focus_interval_id: Uuid::new_v4().to_string(),
|
||||
focus_reason: FocusReason::InitialFocus,
|
||||
active_project_id: None,
|
||||
}),
|
||||
None,
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::EditorFocusEnd(FocusEnd {
|
||||
focus_interval_id: Uuid::new_v4().to_string(),
|
||||
blur_reason: BlurReason::WindowBlur,
|
||||
focus_duration_ms: None,
|
||||
active_project_id: None,
|
||||
}),
|
||||
None,
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::ProjectCreateSuccess(ProjectCreated {
|
||||
creation_source: CreationSource::HomeGame,
|
||||
project_template_id: None,
|
||||
}),
|
||||
Some("project".into()),
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::ProjectOpen(ProjectOpened {
|
||||
open_source: OpenSource::Recent,
|
||||
is_first_open: None,
|
||||
}),
|
||||
Some("project".into()),
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::CreativeTaskSubmit(EmptyProperties {}),
|
||||
Some("project".into()),
|
||||
Source::Direct,
|
||||
),
|
||||
(
|
||||
EventData::AgentRunCompleted(run()),
|
||||
Some("project".into()),
|
||||
Source::Direct,
|
||||
),
|
||||
(
|
||||
EventData::AgentRunFailed(failed),
|
||||
Some("project".into()),
|
||||
Source::Direct,
|
||||
),
|
||||
(
|
||||
EventData::ProjectRevisionCreated(RevisionCreated {
|
||||
revision_id: "42".into(),
|
||||
revision_source: RevisionSource::UiEditor,
|
||||
change_kind: ChangeKind::Ui,
|
||||
files_changed_count: Some(1),
|
||||
}),
|
||||
Some("project".into()),
|
||||
Source::UiEditor,
|
||||
),
|
||||
(
|
||||
EventData::PreviewReady(PreviewReady {
|
||||
preview_source: PreviewSource::User,
|
||||
preview_version: "42".into(),
|
||||
ready_duration_ms: None,
|
||||
}),
|
||||
Some("project".into()),
|
||||
Source::Editor,
|
||||
),
|
||||
(
|
||||
EventData::ProjectSave(ProjectSaved {
|
||||
save_source: SaveSource::Checkpoint,
|
||||
revision_id: Some("42".into()),
|
||||
}),
|
||||
Some("project".into()),
|
||||
Source::Manual,
|
||||
),
|
||||
];
|
||||
values
|
||||
.into_iter()
|
||||
.map(|(data, project, source)| {
|
||||
let identity = matches!(
|
||||
data,
|
||||
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
|
||||
)
|
||||
.then(|| RunIdentity {
|
||||
run_id: Uuid::new_v4().to_string(),
|
||||
turn_id: Some("real-turn".into()),
|
||||
error_code: matches!(data, EventData::AgentRunFailed(_))
|
||||
.then_some(ErrorCode::RuntimeErrorUnclassified),
|
||||
});
|
||||
context().capture(data, project, source, identity).unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_events_round_trip_with_explicit_nullable_envelopes() {
|
||||
let events = samples();
|
||||
assert_eq!(events.len(), 12);
|
||||
for event in events {
|
||||
let value = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(value.as_object().unwrap().len(), 15);
|
||||
let decoded: Event = serde_json::from_value(value).unwrap();
|
||||
decoded.validate().unwrap();
|
||||
assert_eq!(decoded.event_id, event.event_id);
|
||||
assert_eq!(decoded.event_time, event.event_time);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_top_level_fields_and_extra_fields_are_rejected() {
|
||||
for event in samples() {
|
||||
let value = serde_json::to_value(event).unwrap();
|
||||
for field in value.as_object().unwrap().keys() {
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<Event>(missing).is_err(),
|
||||
"missing {field}"
|
||||
);
|
||||
}
|
||||
let mut extra = value;
|
||||
extra["prompt"] = json!("must not be accepted");
|
||||
assert!(serde_json::from_value::<Event>(extra).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_properties_are_closed_and_nullable_fields_are_required() {
|
||||
for event in samples() {
|
||||
let mut extra = event.clone();
|
||||
extra.properties["access_token"] = json!("never collected");
|
||||
assert!(extra.validate().is_err());
|
||||
for (field, value) in event.properties.as_object().unwrap() {
|
||||
if !value.is_null() {
|
||||
continue;
|
||||
}
|
||||
let mut missing = event.clone();
|
||||
missing.properties.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
missing.validate().is_err(),
|
||||
"{} missing {field}",
|
||||
event.event_name
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut nested = samples().pop().unwrap();
|
||||
nested.properties["pending_approval"] =
|
||||
json!({"request_id":"a", "phase":"concept", "question":"private"});
|
||||
assert!(nested.validate().is_err());
|
||||
nested.properties["pending_approval"] = Value::Null;
|
||||
nested.properties["pending_clarification"] = json!({"request_id":"a", "options":[]});
|
||||
assert!(nested.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_identity_status_and_numbers_are_rejected() {
|
||||
let event = samples()
|
||||
.into_iter()
|
||||
.find(|e| e.event_name == "agent_run_completed")
|
||||
.unwrap();
|
||||
let mutate: Vec<(&str, Value)> = vec![
|
||||
("creative_task_id", json!("other-project")),
|
||||
("project_id", Value::Null),
|
||||
("agent_run_id", json!("project")),
|
||||
("status", json!("failed")),
|
||||
("source", json!("design_agent")),
|
||||
("error_code", json!("runtime_failed")),
|
||||
("schema_version", json!(2)),
|
||||
("event_time", json!("2026-09-21T00:00:00Z")),
|
||||
("event_id", json!("not-a-uuid")),
|
||||
("user_id", json!("")),
|
||||
];
|
||||
for (key, value) in mutate {
|
||||
let mut raw = serde_json::to_value(&event).unwrap();
|
||||
raw[key] = value;
|
||||
let invalid: Event = serde_json::from_value(raw).unwrap();
|
||||
assert!(invalid.validate().is_err(), "{key}");
|
||||
}
|
||||
for (key, value) in [
|
||||
("duration_ms", json!(-1)),
|
||||
("retry_index", json!(MAX_SAFE_INTEGER + 1)),
|
||||
("duration_ms", json!(1.5)),
|
||||
] {
|
||||
let mut invalid = event.clone();
|
||||
invalid.properties[key] = value;
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_context_and_origin_do_not_inherit_new_account() {
|
||||
let a = context();
|
||||
let mut b = a.clone();
|
||||
b.route = Route::from_identity(
|
||||
Some("456".into()),
|
||||
Some("https://other.example.com/api?token=private"),
|
||||
);
|
||||
let event = a
|
||||
.capture(
|
||||
EventData::CreativeTaskSubmit(EmptyProperties {}),
|
||||
Some("p".into()),
|
||||
Source::Direct,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(event.user_id.as_deref(), Some("123"));
|
||||
assert_eq!(
|
||||
a.route.destination_origin.as_deref(),
|
||||
Some("https://example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
b.route.destination_origin.as_deref(),
|
||||
Some("https://other.example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
Route::from_identity(None, Some("file:///private")).destination_origin,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
Route::from_identity(None, Some("https://user:secret@example.com")).destination_origin,
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
Route {
|
||||
user_id: None,
|
||||
destination_origin: Some("https://example.com/api".into())
|
||||
}
|
||||
.validate()
|
||||
== false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 策划阶段推进按约定视为成果变化;不检查文档版本或记录工作流快照。
|
||||
use super::contract::{ChangeKind, Context, EventData, RevisionCreated, RevisionSource, Source};
|
||||
use super::store::AnalyticsWriter;
|
||||
|
||||
pub(crate) struct PhaseChange {
|
||||
context: Context,
|
||||
writer: AnalyticsWriter,
|
||||
project_id: String,
|
||||
revision_id: String,
|
||||
}
|
||||
|
||||
impl PhaseChange {
|
||||
pub(crate) fn new(
|
||||
capture: Option<(Context, AnalyticsWriter)>,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
previous_phase: &str,
|
||||
phase: &str,
|
||||
) -> Option<Self> {
|
||||
if previous_phase == phase {
|
||||
return None;
|
||||
}
|
||||
let (context, writer) = capture?;
|
||||
Some(Self {
|
||||
context,
|
||||
writer,
|
||||
project_id: project_id.to_string(),
|
||||
revision_id: format!("design:{session_id}:{phase}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn revision_id(&self) -> &str {
|
||||
&self.revision_id
|
||||
}
|
||||
|
||||
// 必须在阶段与审批命令成功持久化后调用,时间与事件 ID 在此刻生成。
|
||||
pub(crate) fn record(self) {
|
||||
let key = format!(
|
||||
"{}:{}:project_revision_created",
|
||||
self.project_id, self.revision_id
|
||||
);
|
||||
let data = EventData::ProjectRevisionCreated(RevisionCreated {
|
||||
revision_id: self.revision_id,
|
||||
revision_source: RevisionSource::Agent,
|
||||
change_kind: ChangeKind::DesignDocument,
|
||||
files_changed_count: None,
|
||||
});
|
||||
if let Ok(event) =
|
||||
self.context
|
||||
.capture(data, Some(self.project_id), Source::DesignAgent, None)
|
||||
{
|
||||
self.writer.try_record(self.context.route, event, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 新建项目的目标采集资格。只在后台持锁读改写,不等待业务线程。
|
||||
use super::contract::{Context, EmptyProperties, Event, EventData, Route, Source};
|
||||
use super::store::AnalyticsWriter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MARKER: &str = ".agent/analytics-goal.json";
|
||||
const MAX_MARKER_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) enum Request {
|
||||
Created {
|
||||
root: PathBuf,
|
||||
project_id: String,
|
||||
},
|
||||
Accepted {
|
||||
root: PathBuf,
|
||||
route: Route,
|
||||
event: Event,
|
||||
},
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub(super) fn validate(&self) -> bool {
|
||||
let (root, project_id) = match self {
|
||||
Self::Created { root, project_id } => (root, Some(project_id.as_str())),
|
||||
Self::Accepted { root, route, event } => {
|
||||
if !route.validate()
|
||||
|| route.user_id != event.user_id
|
||||
|| event.event_name != "creative_task_submit"
|
||||
|| event.validate().is_err()
|
||||
|| !matches!(event.source, Source::Direct | Source::DesignAgent)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
(root, event.project_id.as_deref())
|
||||
}
|
||||
};
|
||||
root.is_absolute()
|
||||
&& root.as_os_str().len() <= 32768
|
||||
&& project_id.is_some_and(|id| {
|
||||
!id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Marker {
|
||||
schema_version: u32,
|
||||
project_id: String,
|
||||
submitted: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn created(writer: &AnalyticsWriter, root: &Path, project_id: &str) {
|
||||
writer.try_goal(Request::Created {
|
||||
root: root.into(),
|
||||
project_id: project_id.into(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn accepted(
|
||||
capture: Option<(Context, AnalyticsWriter)>,
|
||||
root: &Path,
|
||||
project_id: &str,
|
||||
source: Source,
|
||||
) {
|
||||
if !matches!(source, Source::Direct | Source::DesignAgent) {
|
||||
return;
|
||||
}
|
||||
let Some((context, writer)) = capture else {
|
||||
return;
|
||||
};
|
||||
let Ok(event) = context.capture(
|
||||
EventData::CreativeTaskSubmit(EmptyProperties {}),
|
||||
Some(project_id.into()),
|
||||
source,
|
||||
None,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
writer.try_goal(Request::Accepted {
|
||||
root: root.into(),
|
||||
route: context.route,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
/// 成功消费资格才返回事件;消费后队列落盘失败允许漏记。
|
||||
pub(super) fn process(request: Request) -> Result<Option<(Route, Event)>, String> {
|
||||
if !request.validate() {
|
||||
return Err("invalid analytics goal request".into());
|
||||
}
|
||||
let (root, project_id) = match &request {
|
||||
Request::Created { root, project_id } => (root, project_id.as_str()),
|
||||
Request::Accepted { root, event, .. } => (root, event.project_id.as_deref().unwrap()),
|
||||
};
|
||||
let Some(_lock) =
|
||||
crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-goal")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?;
|
||||
if crate::project::read_manifest(&manifest_path)?.project_id != project_id {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = crate::project::resolve_local_project_path(root, MARKER)?;
|
||||
let primary = std::fs::symlink_metadata(&path);
|
||||
match &request {
|
||||
Request::Created { .. } => {
|
||||
// 缺失主文件但存在恢复副本仍属未知,不能重新授予资格。
|
||||
let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path);
|
||||
if !matches!(primary, Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
|
||||
|| !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
crate::agent::write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
MARKER,
|
||||
"埋点目标资格",
|
||||
&Marker {
|
||||
schema_version: 1,
|
||||
project_id: project_id.into(),
|
||||
submitted: false,
|
||||
},
|
||||
MAX_MARKER_BYTES,
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
Request::Accepted { .. } => {
|
||||
// 明确要求主文件存在,不启用 sidecar 的 previous 自动回退。
|
||||
let Ok(metadata) = primary else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut marker): Option<Marker> =
|
||||
crate::agent::read_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
MARKER,
|
||||
"埋点目标资格",
|
||||
MAX_MARKER_BYTES,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if marker.schema_version != 1 || marker.project_id != project_id || marker.submitted {
|
||||
return Ok(None);
|
||||
}
|
||||
marker.submitted = true;
|
||||
crate::agent::write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
MARKER,
|
||||
"埋点目标资格",
|
||||
&marker,
|
||||
MAX_MARKER_BYTES,
|
||||
)?;
|
||||
match request {
|
||||
Request::Accepted { route, event, .. } => Ok(Some((route, event))),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
fn state() -> (tempfile::TempDir, GuiState) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let context = Context {
|
||||
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
|
||||
editor_session_id: Uuid::new_v4().to_string(),
|
||||
client_version: "1.0.0".into(),
|
||||
};
|
||||
let writer = AnalyticsWriter::start(dir.path().into(), context.editor_session_id.clone());
|
||||
(dir, GuiState::new(context, 1, writer))
|
||||
}
|
||||
|
||||
fn read_events(dir: &std::path::Path, session: &str, expected: usize) -> Vec<Event> {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let mut events = Vec::new();
|
||||
let batches = dir
|
||||
.join("analytics/instances")
|
||||
.join(session)
|
||||
.join("batches");
|
||||
if let Ok(entries) = fs::read_dir(batches) {
|
||||
for entry in entries.flatten() {
|
||||
if Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = fs::read_to_string(entry.path().join("events.jsonl")) {
|
||||
events.extend(
|
||||
text.lines()
|
||||
.map(|line| serde_json::from_str::<Event>(line).unwrap()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if events.len() == expected {
|
||||
return events;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < until,
|
||||
"expected {expected} events, found {}",
|
||||
events.len()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_union_and_repeated_notifications_form_one_interval() {
|
||||
let (dir, mut state) = state();
|
||||
state.start();
|
||||
state.windows(Some("main".into()), false, true);
|
||||
let interval = state.focus.as_ref().unwrap().id.clone();
|
||||
state.windows(Some("launcher".into()), false, false);
|
||||
state.windows(Some("launcher".into()), false, false);
|
||||
assert_eq!(state.focus.as_ref().unwrap().id, interval);
|
||||
state.windows(None, true, false);
|
||||
state.windows(None, true, false);
|
||||
state.exit();
|
||||
state.exit();
|
||||
let events = read_events(dir.path(), &state.context.editor_session_id, 4);
|
||||
let end = events
|
||||
.iter()
|
||||
.find(|e| e.event_name == "editor_focus_end")
|
||||
.unwrap();
|
||||
assert_eq!(end.properties["focus_interval_id"], interval);
|
||||
assert_eq!(end.properties["blur_reason"], "minimized");
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| e.event_name == "editor_session_end")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_change_splits_focus_but_refresh_and_stale_notice_do_not() {
|
||||
let (dir, mut state) = state();
|
||||
state.start();
|
||||
state.windows(Some("main".into()), false, true);
|
||||
let first = state.focus.as_ref().unwrap().id.clone();
|
||||
open(
|
||||
&mut state,
|
||||
"project-a",
|
||||
&Uuid::new_v4().to_string(),
|
||||
"2000-01-01T00:00:00.000Z",
|
||||
);
|
||||
state.identity(state.context.route.clone(), 2);
|
||||
assert_eq!(state.focus.as_ref().unwrap().id, first);
|
||||
let route_b = Route::from_identity(Some("B".into()), Some("https://example.com"));
|
||||
state.identity(route_b.clone(), 4);
|
||||
state.identity(
|
||||
Route::from_identity(Some("A".into()), Some("https://example.com")),
|
||||
3,
|
||||
);
|
||||
assert_eq!(state.context.route, route_b);
|
||||
assert!(state.projects.is_empty());
|
||||
assert!(state.active_project.is_none());
|
||||
assert_ne!(state.focus.as_ref().unwrap().id, first);
|
||||
state.restart = true;
|
||||
state.exit();
|
||||
let events = read_events(dir.path(), &state.context.editor_session_id, 7);
|
||||
let old_end = events
|
||||
.iter()
|
||||
.find(|e| e.event_name == "editor_focus_end" && e.user_id.as_deref() == Some("A"))
|
||||
.unwrap();
|
||||
assert_eq!(old_end.properties["blur_reason"], "account_change");
|
||||
let new_start = events
|
||||
.iter()
|
||||
.find(|e| e.event_name == "editor_focus_start" && e.user_id.as_deref() == Some("B"))
|
||||
.unwrap();
|
||||
assert_eq!(new_start.properties["focus_reason"], "account_change");
|
||||
assert!(new_start.project_id.is_none());
|
||||
let end = events
|
||||
.iter()
|
||||
.find(|e| e.event_name == "editor_session_end")
|
||||
.unwrap();
|
||||
assert_eq!(end.properties["end_reason"], "app_restart");
|
||||
}
|
||||
|
||||
fn open(state: &mut GuiState, project_id: &str, operation: &str, time: &str) {
|
||||
state.reserve_open("main", operation, project_id, time);
|
||||
state.project_opened(
|
||||
"main".into(),
|
||||
state.context.clone(),
|
||||
OpenProject {
|
||||
id: project_id.into(),
|
||||
path: project_id.into(),
|
||||
operation_id: operation.into(),
|
||||
},
|
||||
OpenSource::Recent,
|
||||
time.into(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_reopen_records_new_operation_and_duplicate_delivery_does_not() {
|
||||
let (dir, mut state) = state();
|
||||
let id = Uuid::new_v4().to_string();
|
||||
open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z");
|
||||
// 同一次已采纳操作的重复回执由幂等事实键抑制。
|
||||
open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z");
|
||||
let next = Uuid::new_v4().to_string();
|
||||
assert_eq!(state.projects.get("main").unwrap().operation_id, id);
|
||||
open(&mut state, "project", &next, "2026-09-21T12:00:00.002Z");
|
||||
state.writer.flush();
|
||||
let events = read_events(dir.path(), &state.context.editor_session_id, 2);
|
||||
assert!(events.iter().any(|e| e.event_id == id));
|
||||
assert!(events.iter().any(|e| e.event_id == next));
|
||||
assert!(events[0].properties["is_first_open"].is_null());
|
||||
assert_eq!(events[1].properties["is_first_open"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_capture_or_manifest_read_does_not_restore_a_left_project() {
|
||||
let (dir, mut state) = state();
|
||||
state.windows(Some("main".into()), false, true);
|
||||
let old = Uuid::new_v4().to_string();
|
||||
state.reserve_open("main", &old, "old", "2026-09-21T12:00:00.001Z");
|
||||
let new = Uuid::new_v4().to_string();
|
||||
open(&mut state, "new", &new, "2026-09-21T12:00:00.002Z");
|
||||
state.project_opened(
|
||||
"main".into(),
|
||||
state.context.clone(),
|
||||
OpenProject {
|
||||
id: "old".into(),
|
||||
path: "old".into(),
|
||||
operation_id: old,
|
||||
},
|
||||
OpenSource::Recent,
|
||||
"2026-09-21T12:00:00.001Z".into(),
|
||||
);
|
||||
assert_eq!(state.active_project.as_deref(), Some("new"));
|
||||
state.leave("main", Some("new"));
|
||||
let late = Uuid::new_v4().to_string();
|
||||
open(&mut state, "old", &late, "2000-01-01T00:00:00.000Z");
|
||||
assert!(state.active_project.is_none());
|
||||
state.writer.flush();
|
||||
let events = read_events(dir.path(), &state.context.editor_session_id, 4);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| e.event_name == "project_open")
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 客户端产品埋点:本地采集与持久化,不发起上传。
|
||||
pub(crate) mod contract;
|
||||
pub(crate) mod design;
|
||||
pub(crate) mod goal;
|
||||
pub(crate) mod gui;
|
||||
pub(crate) mod preview;
|
||||
pub(crate) mod project;
|
||||
pub(crate) mod run;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod store;
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 正式 Web 预览的短时可访问性观察;不影响预览业务生命周期。
|
||||
use super::{
|
||||
contract::{Context, EventData, PreviewReady, PreviewSource, Source},
|
||||
store::AnalyticsWriter,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Weak},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(crate) struct Lease {
|
||||
_alive: Arc<()>,
|
||||
}
|
||||
|
||||
pub(crate) struct Observation {
|
||||
alive: Weak<()>,
|
||||
instance_id: String,
|
||||
root: PathBuf,
|
||||
entry: PathBuf,
|
||||
project_id: String,
|
||||
revision: u64,
|
||||
context: Context,
|
||||
writer: AnalyticsWriter,
|
||||
source: Source,
|
||||
preview_source: PreviewSource,
|
||||
started: Instant,
|
||||
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
fn nonempty_entry(entry: &Path) -> bool {
|
||||
std::fs::metadata(entry).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(
|
||||
root: &Path,
|
||||
capture: Option<(Context, AnalyticsWriter)>,
|
||||
source: Source,
|
||||
preview_source: PreviewSource,
|
||||
) -> Option<(Lease, Observation)> {
|
||||
let (context, writer) = capture?;
|
||||
let entry = crate::project_game_root(root).join("index.html");
|
||||
if !nonempty_entry(&entry) {
|
||||
return None;
|
||||
}
|
||||
let project_id = crate::read_existing_manifest_for_project(root)
|
||||
.ok()?
|
||||
.project_id;
|
||||
let revision = crate::read_game_creator_agent_runtime_project_revision(root)
|
||||
.ok()?
|
||||
.revision;
|
||||
let alive = Arc::new(());
|
||||
let observation = Observation {
|
||||
alive: Arc::downgrade(&alive),
|
||||
instance_id: uuid::Uuid::new_v4().to_string(),
|
||||
root: root.into(),
|
||||
entry,
|
||||
project_id,
|
||||
revision,
|
||||
context,
|
||||
writer,
|
||||
source,
|
||||
preview_source,
|
||||
started: Instant::now(),
|
||||
cancellation: None,
|
||||
};
|
||||
Some((Lease { _alive: alive }, observation))
|
||||
}
|
||||
|
||||
impl Observation {
|
||||
pub(crate) fn with_cancellation(
|
||||
mut self,
|
||||
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Self {
|
||||
self.cancellation = cancellation;
|
||||
self
|
||||
}
|
||||
|
||||
fn current(&self) -> bool {
|
||||
!self
|
||||
.cancellation
|
||||
.as_ref()
|
||||
.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire))
|
||||
&& self.alive.strong_count() > 0
|
||||
&& self.entry == crate::project_game_root(&self.root).join("index.html")
|
||||
&& nonempty_entry(&self.entry)
|
||||
&& crate::read_game_creator_agent_runtime_project_revision(&self.root)
|
||||
.is_ok_and(|revision| revision.revision == self.revision)
|
||||
&& crate::read_existing_manifest_for_project(&self.root)
|
||||
.is_ok_and(|manifest| manifest.project_id == self.project_id)
|
||||
}
|
||||
|
||||
pub(crate) fn schedule(self, port: u16) {
|
||||
tauri::async_runtime::spawn(self.observe(port));
|
||||
}
|
||||
|
||||
pub(crate) async fn observe(self, port: u16) {
|
||||
if !self.current() {
|
||||
return;
|
||||
}
|
||||
let reachable = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.ok()?;
|
||||
let mut response = client
|
||||
.get(format!("http://127.0.0.1:{port}/"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
while let Some(chunk) = response.chunk().await.ok()? {
|
||||
if !chunk.is_empty() {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
if !matches!(reachable, Ok(Some(()))) || !self.current() {
|
||||
return;
|
||||
}
|
||||
let event = self.context.capture(
|
||||
EventData::PreviewReady(PreviewReady {
|
||||
preview_source: self.preview_source,
|
||||
preview_version: self.revision.to_string(),
|
||||
ready_duration_ms: u64::try_from(self.started.elapsed().as_millis()).ok(),
|
||||
}),
|
||||
Some(self.project_id.clone()),
|
||||
self.source,
|
||||
None,
|
||||
);
|
||||
if let Ok(event) = event {
|
||||
if self.current() {
|
||||
let key = format!(
|
||||
"{}:{}:{}:preview_ready",
|
||||
self.project_id, self.instance_id, self.revision
|
||||
);
|
||||
self.writer.try_record(self.context.route, event, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 正式项目成果事件。只接收宿主已提交的版本,不参与业务写入。
|
||||
use super::contract::{ChangeKind, Context, EventData, ProjectSaved, RevisionCreated, Source};
|
||||
use super::store::AnalyticsWriter;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn saved(
|
||||
capture: Option<(Context, AnalyticsWriter)>,
|
||||
project_id: &str,
|
||||
source: Source,
|
||||
operation_id: &str,
|
||||
data: ProjectSaved,
|
||||
event_time: Option<&str>,
|
||||
) {
|
||||
let Some((context, writer)) = capture else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut event) = context.capture(
|
||||
EventData::ProjectSave(data),
|
||||
Some(project_id.into()),
|
||||
source,
|
||||
None,
|
||||
) {
|
||||
if let Some(time) = event_time {
|
||||
event.event_time = time.into();
|
||||
}
|
||||
writer.try_record(
|
||||
context.route,
|
||||
event,
|
||||
format!("{project_id}:{operation_id}:project_save"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn file_content_changed(
|
||||
root: &Path,
|
||||
path: &str,
|
||||
content: &[u8],
|
||||
max_bytes: u64,
|
||||
) -> Option<bool> {
|
||||
let target = crate::resolve_local_project_path(root, path).ok()?;
|
||||
match std::fs::symlink_metadata(&target) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(true),
|
||||
Err(_) => return None,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let (file, metadata) =
|
||||
crate::open_project_snapshot_regular_file(&target, "成果内容比较").ok()?;
|
||||
if metadata.len() > max_bytes {
|
||||
return None;
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
file.take(max_bytes.saturating_add(1))
|
||||
.read_to_end(&mut before)
|
||||
.ok()?;
|
||||
(before.len() as u64 <= max_bytes).then(|| before != content)
|
||||
}
|
||||
|
||||
pub(crate) fn revision(
|
||||
capture: Option<(Context, AnalyticsWriter)>,
|
||||
project_id: &str,
|
||||
source: Source,
|
||||
data: RevisionCreated,
|
||||
) {
|
||||
let Some((context, writer)) = capture else {
|
||||
return;
|
||||
};
|
||||
let key = format!("{project_id}:{}:project_revision_created", data.revision_id);
|
||||
if let Ok(event) = context.capture(
|
||||
EventData::ProjectRevisionCreated(data),
|
||||
Some(project_id.into()),
|
||||
source,
|
||||
None,
|
||||
) {
|
||||
writer.try_record(context.route, event, key);
|
||||
}
|
||||
}
|
||||
|
||||
// 只给已知成果分类;未知文件不猜字段,控制面和构建缓存不计成果。
|
||||
pub(crate) fn file_change_kind(path: &str) -> Option<ChangeKind> {
|
||||
let path = path.to_ascii_lowercase();
|
||||
if crate::should_skip_project_snapshot_path(&path)
|
||||
|| path.split('/').any(|part| part.starts_with('.'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if path.starts_with("design_artifacts/") {
|
||||
return Some(ChangeKind::DesignDocument);
|
||||
}
|
||||
if path.starts_with("ui/") {
|
||||
return Some(ChangeKind::Ui);
|
||||
}
|
||||
let extension = path.rsplit_once('.')?.1;
|
||||
match extension {
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "mp3" | "wav" | "ogg"
|
||||
| "flac" | "mp4" | "webm" | "glb" | "gltf" | "ttf" | "woff" | "woff2" => {
|
||||
Some(ChangeKind::Asset)
|
||||
}
|
||||
"js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "html" | "css" | "scss" | "json" | "vue"
|
||||
| "svelte" | "gd" | "tscn" | "tres" | "cs" | "shader" | "glsl" | "wgsl" | "vert"
|
||||
| "frag" | "rs" | "py" | "lua" | "cpp" | "h" | "c" | "hpp" => Some(ChangeKind::Code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user