合并 master 最新变更
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 2m16s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Successful in 6m45s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 14m9s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 14m23s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 2m16s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Successful in 6m45s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 14m9s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 14m23s
# Conflicts: # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse {
|
||||
export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc';
|
||||
export type AdminAgcModelProtocol =
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'anthropic';
|
||||
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
@@ -1083,6 +1087,8 @@ export interface AdminAgcModel {
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
agentMode: AdminAgcAgentMode;
|
||||
/** 上游线协议,与执行器正交。 */
|
||||
protocol?: AdminAgcModelProtocol;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
|
||||
@@ -104,3 +104,49 @@ test('new models default to Codex and save the selected Agent mode', async () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{
|
||||
id: 'quality',
|
||||
alias: '高质量',
|
||||
modelId: 'claude-opus-5-5',
|
||||
enabled: true,
|
||||
agentMode: 'cc' as const,
|
||||
protocol: 'anthropic' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('claude-opus-5-5');
|
||||
|
||||
const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement;
|
||||
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
|
||||
expect(agentMode.value).toBe('cc');
|
||||
expect(protocol.value).toBe('anthropic');
|
||||
// 执行器下拉里不再混入协议取值。
|
||||
expect(
|
||||
Array.from(agentMode.options).map((option) => option.value),
|
||||
).toEqual(['codex', 'cc']);
|
||||
|
||||
fireEvent.change(agentMode, { target: { value: 'codex' } });
|
||||
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
|
||||
const payload = calls[calls.length - 1]?.[1];
|
||||
expect(payload?.models[0]).toMatchObject({
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
|
||||
{ key: 'alias', label: '别名' },
|
||||
{ key: 'modelId', label: '实际模型名' },
|
||||
{ key: 'agentMode', label: 'Agent 模式' },
|
||||
{ key: 'protocol', label: '协议' },
|
||||
{ key: 'enabled', label: '启用' },
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'actions', label: '操作' },
|
||||
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_responses',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -226,7 +228,22 @@ export function AdminAgcModelsPage({
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="cc">Claude Code</option>
|
||||
<option value="anthropic">Anthropic 协议</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`模型 ${index + 1} 协议`}
|
||||
value={model.protocol ?? 'openai_responses'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
protocol: e.target.value as AdminAgcModel['protocol'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai_responses">OpenAI Responses</option>
|
||||
<option value="openai_chat">OpenAI Chat Completions</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -313,7 +313,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Claude Agent SDK sidecar 的 Node runtime 不可用".to_string())?;
|
||||
.map_err(|error| {
|
||||
// `output()` 失败只说明进程没能启动(可执行文件缺失、权限不足或被拦截),
|
||||
// 不能笼统说成「Node runtime 不可用」并丢掉 OS 错误。
|
||||
format!("Claude Agent SDK sidecar 的 Node runtime 启动失败:{error}")
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err("Claude Agent SDK sidecar 的 Node runtime 版本检查失败".to_string());
|
||||
}
|
||||
|
||||
@@ -1971,6 +1971,28 @@ fn configure_game_creator_codex_app_server_command(
|
||||
)
|
||||
}
|
||||
|
||||
/// Codex 的 `wire_api` 取自所选模型的线协议。
|
||||
///
|
||||
/// 单测不读全局配置:用例里没有真实的模型选择,读它会引入环境依赖,所以测试态固定
|
||||
/// `responses`,真实构建按 `selected_model_protocol` 在 `responses` / `chat` 间切换。
|
||||
fn game_creator_codex_provider_wire_api() -> &'static str {
|
||||
#[cfg(test)]
|
||||
{
|
||||
"responses"
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
match crate::config::load_game_creator_app_config()
|
||||
.ok()
|
||||
.and_then(|config| config.selected_model_protocol)
|
||||
.as_deref()
|
||||
{
|
||||
Some("openai_chat") => "chat",
|
||||
_ => "responses",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
command: &mut tokio::process::Command,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
@@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
let provider_token = provider_proxy
|
||||
.map(CodexProviderProxy::downstream_bearer_token)
|
||||
.unwrap_or(legacy_api_key);
|
||||
// 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions,
|
||||
// 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。
|
||||
let wire_api = game_creator_codex_provider_wire_api();
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
@@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode(
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}",
|
||||
quoted_toml_string("responses")?
|
||||
quoted_toml_string(wire_api)?
|
||||
))
|
||||
.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token);
|
||||
}
|
||||
|
||||
@@ -7338,7 +7338,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(error
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
|
||||
assert!(error.contains("<redacted-url>"), "{error}");
|
||||
assert!(error.contains("<absolute-path>"), "{error}");
|
||||
assert!(!error.contains("authorization=Bearer secret"), "{error}");
|
||||
@@ -7382,7 +7382,7 @@ mod tests {
|
||||
|
||||
assert!(
|
||||
error.starts_with(
|
||||
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
|
||||
"direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary="
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
@@ -7421,7 +7421,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(
|
||||
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
|
||||
error.contains("stage=art-preparation code=runtime-unclassified retryable=false"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
|
||||
|
||||
@@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
let scope_relative = scope
|
||||
.and_then(|scope| scope.project_relative)
|
||||
.filter(|path| !path.is_empty());
|
||||
let listed = list_local_project_files_at(root)?;
|
||||
let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/"));
|
||||
let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| {
|
||||
bridge_project_file_is_hidden_control_path(path)
|
||||
|| should_skip_project_snapshot_path(path)
|
||||
|| reject_sensitive_project_file_read(path).is_err()
|
||||
})?;
|
||||
let mut files = listed
|
||||
.files
|
||||
.into_iter()
|
||||
.filter(|file| file.kind == "file")
|
||||
.filter(|file| !bridge_project_file_is_hidden_control_path(&file.path))
|
||||
.filter(|file| !should_skip_project_snapshot_path(&file.path))
|
||||
.filter(|file| reject_sensitive_project_file_read(&file.path).is_ok())
|
||||
.filter(|file| {
|
||||
scope_relative.as_ref().is_none_or(|scope| {
|
||||
file.path == *scope
|
||||
|| scope_prefix
|
||||
.as_ref()
|
||||
.is_some_and(|prefix| file.path.starts_with(prefix))
|
||||
})
|
||||
})
|
||||
.filter(|file| {
|
||||
let (category, _) = bridge_project_file_class(&file.path);
|
||||
requested_kind == "all" || requested_kind == category
|
||||
@@ -1622,11 +1614,13 @@ async fn bridge_import_account_assets(
|
||||
}
|
||||
}
|
||||
if !local_paths.is_empty() {
|
||||
match crate::commands::import_local_project_assets_for_agent_with_write_permit(
|
||||
match crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
|
||||
&state.root,
|
||||
&local_paths,
|
||||
write_permit.as_ref(),
|
||||
) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
|
||||
json!({
|
||||
"id": asset.id,
|
||||
@@ -4193,6 +4187,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap();
|
||||
for path in [
|
||||
"assets/z.png",
|
||||
"assets/nested/b.png",
|
||||
"assets/a.png",
|
||||
"assets/notes.txt",
|
||||
"assets-other/wrong.png",
|
||||
"unrelated/wrong.png",
|
||||
".agent/hidden.png",
|
||||
"assets/.env",
|
||||
"assets/secret.key",
|
||||
"assets/.codex/hidden.png",
|
||||
"assets/node_modules/hidden.png",
|
||||
"assets/.git/hidden.png",
|
||||
] {
|
||||
let target = root.join(path);
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(target, b"test-only").unwrap();
|
||||
}
|
||||
let payload = |arguments: Value| {
|
||||
let result = bridge_list_project_files(root, &arguments);
|
||||
assert_eq!(result["isError"], false, "{result}");
|
||||
serde_json::from_str::<Value>(result["content"][0]["text"].as_str().unwrap()).unwrap()
|
||||
};
|
||||
let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 }));
|
||||
assert_eq!(first["total"], 3);
|
||||
assert_eq!(first["offset"], 0);
|
||||
assert_eq!(first["limit"], 2);
|
||||
assert_eq!(first["nextOffset"], 2);
|
||||
assert_eq!(first["files"][0]["path"], "assets/a.png");
|
||||
assert_eq!(first["files"][1]["path"], "assets/nested/b.png");
|
||||
let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 }));
|
||||
assert_eq!(last["total"], 3);
|
||||
assert_eq!(last["files"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(last["files"][0]["path"], "assets/z.png");
|
||||
assert!(last["nextOffset"].is_null());
|
||||
for path in ["assets/nested", "assets/nested/b.png"] {
|
||||
let page = payload(json!({ "path": path }));
|
||||
assert_eq!(page["total"], 1);
|
||||
assert_eq!(page["files"][0]["path"], "assets/nested/b.png");
|
||||
}
|
||||
assert_eq!(
|
||||
payload(json!({ "path": "assets", "query": "NESTED" }))["total"],
|
||||
1
|
||||
);
|
||||
assert_eq!(payload(json!({ "path": "assets" }))["total"], 4);
|
||||
assert_eq!(payload(json!({ "path": "missing" }))["total"], 0);
|
||||
let beyond = payload(json!({ "path": "assets", "offset": 20 }));
|
||||
assert!(beyond["files"].as_array().unwrap().is_empty());
|
||||
assert!(beyond["nextOffset"].is_null());
|
||||
for path in [
|
||||
".agent",
|
||||
"assets/.env",
|
||||
"assets/.codex",
|
||||
"assets/node_modules",
|
||||
] {
|
||||
assert_eq!(
|
||||
bridge_list_project_files(root, &json!({ "path": path }))["isError"],
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
|
||||
use crate::analytics::{
|
||||
@@ -4748,21 +4809,39 @@ mod tests {
|
||||
let revision_before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
session
|
||||
.interrupt("fixture cancellation before asset commit".into())
|
||||
.unwrap();
|
||||
assert!(
|
||||
crate::commands::import_local_project_assets_for_agent_with_write_permit(
|
||||
assert!(crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
|
||||
root,
|
||||
&["game/import-source.png".to_string()],
|
||||
Some(&permit),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit)
|
||||
.is_err()
|
||||
);
|
||||
),
|
||||
|| session
|
||||
.interrupt("fixture cancellation during asset lock wait".into())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
let commit_root = root.to_path_buf();
|
||||
let commit_bytes = bytes.clone();
|
||||
let commit_permit = permit.clone();
|
||||
assert!(crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::commands::commit_account_asset_for_write_permit_test(
|
||||
&commit_root,
|
||||
&commit_bytes,
|
||||
&commit_permit,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
},
|
||||
|| {}
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
fs::read(root.join(".agent/manifest.json")).unwrap(),
|
||||
manifest_before
|
||||
|
||||
@@ -204,6 +204,12 @@ fn single_line_log_field(value: &str) -> String {
|
||||
value.replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
/// 直连回合失败的持久化标签。
|
||||
///
|
||||
/// 这里是**唯一**的失败分类出口,标签会被写进 `.agent/runtime/errors/*.json` 与
|
||||
/// `direct-codex-failure:v2` 文案。未识别的原因必须落回专门的 `runtime-unclassified`:
|
||||
/// 以前这里借用具体的 `runtime-failure` 兜底,任何新失败形态都会被读成「运行时失败」,
|
||||
/// 真正的失败原因反而被盖掉,也无法从标签看出分类覆盖率。
|
||||
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
if normalized.contains("等待 turn/completed 超时") {
|
||||
@@ -224,8 +230,16 @@ pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
|
||||
"tool-invalid-arguments"
|
||||
} else if normalized.contains("codex app-server-error:other") {
|
||||
"app-server-other"
|
||||
} else if normalized.contains("sidecar") && normalized.contains("超时") {
|
||||
"sidecar-turn-timeout"
|
||||
} else if normalized.contains("sidecar") && normalized.contains("启动失败") {
|
||||
"sidecar-launch-failed"
|
||||
} else if normalized.contains("codex app-server 启动失败") {
|
||||
"app-server-launch-failed"
|
||||
} else if normalized.contains("claude code 返回失败终态") {
|
||||
"claude-code-failed"
|
||||
} else {
|
||||
"runtime-failure"
|
||||
"runtime-unclassified"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,4 +375,39 @@ mod tests {
|
||||
"tool-invalid-arguments"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_sidecar_and_app_server_failures_keep_their_own_codes() {
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("Claude Agent SDK sidecar 回合超时"),
|
||||
"sidecar-turn-timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error(
|
||||
"Claude Agent SDK sidecar 的 Node runtime 启动失败:拒绝访问"
|
||||
),
|
||||
"sidecar-launch-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("Codex app-server 启动失败:找不到可执行文件"),
|
||||
"app-server-launch-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("Claude Code 返回失败终态"),
|
||||
"claude-code-failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclassified_failures_get_a_dedicated_code() {
|
||||
// 任何一个未识别原因都不能被读成某个具体故障:只允许专用兜底码。
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("某种尚未识别的新失败"),
|
||||
"runtime-unclassified"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("读取陶泥儿画布资源失败:<redacted-url>"),
|
||||
"runtime-unclassified"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +583,8 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import(
|
||||
}
|
||||
}
|
||||
if !local_paths.is_empty() {
|
||||
match import_local_project_assets_for_agent(root, &local_paths) {
|
||||
match import_local_project_assets_for_agent_in_blocking_pool(root, &local_paths, None).await
|
||||
{
|
||||
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
|
||||
serde_json::json!({
|
||||
"id": asset.id,
|
||||
@@ -709,6 +710,35 @@ pub(in crate::agent) fn observe_agent_runtime_project_search(
|
||||
mod asset_import_input_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_local_import_waits_without_blocking_worker() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "runtime-import-wait", "Runtime 导入等待").unwrap();
|
||||
fs::write(root.join("game/import.md"), b"# Imported").unwrap();
|
||||
let observation = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
async {
|
||||
let observation = observe_agent_runtime_asset_import(
|
||||
root,
|
||||
&serde_json::json!({"localPaths": ["game/import.md"]}),
|
||||
)
|
||||
.await;
|
||||
if observation.status != "ok" {
|
||||
return Err(format!("{observation:?}"));
|
||||
}
|
||||
Ok(observation)
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let detail: serde_json::Value =
|
||||
serde_json::from_str(observation.detail.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(detail["imported"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(detail["revisionAdvanceCount"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() {
|
||||
let temporary = tempfile::tempdir().expect("create asset context project root");
|
||||
|
||||
@@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
|
||||
}
|
||||
|
||||
let stylesVisible = true;
|
||||
let pointerBlocked = false;
|
||||
// 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。
|
||||
const pointerBlocked = getComputedStyle(this).pointerEvents === 'none';
|
||||
for (let current = this; current instanceof HTMLElement; current = current.parentElement) {
|
||||
const style = getComputedStyle(current);
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
@@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.visibility !== 'collapse'
|
||||
&& (Number.isNaN(opacity) || opacity > 0);
|
||||
pointerBlocked ||= style.pointerEvents === 'none';
|
||||
}
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
|
||||
@@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
|
||||
let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1);
|
||||
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
|
||||
assert_eq!(
|
||||
generic,
|
||||
"e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
|
||||
);
|
||||
assert_eq!(
|
||||
tetris,
|
||||
"b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
|
||||
);
|
||||
assert_eq!(
|
||||
lane,
|
||||
"6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a"
|
||||
[generic.as_str(), tetris.as_str(), lane.as_str()],
|
||||
[
|
||||
"85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d",
|
||||
"60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff",
|
||||
"a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466",
|
||||
]
|
||||
);
|
||||
assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||
assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||
@@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
|
||||
let result = run_stable_generic_viewport_fixture(false).await;
|
||||
let result = run_stable_generic_viewport_fixture(false, None).await;
|
||||
assert!(
|
||||
result.passed,
|
||||
"diagnostics={:#?}\nviewports={:#?}",
|
||||
@@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
|
||||
let result = run_stable_generic_viewport_fixture(true).await;
|
||||
let result = run_stable_generic_viewport_fixture(true, None).await;
|
||||
assert!(
|
||||
!result.passed,
|
||||
"a successful desktop cannot hide mobile gameplay failure"
|
||||
@@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
|
||||
.any(|message| message.starts_with("playtest mobile:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() {
|
||||
let result = run_stable_generic_viewport_fixture(
|
||||
false,
|
||||
Some(
|
||||
r#"<div style="pointer-events:none;padding:20px">
|
||||
<button type="button" data-playtest-id="start" style="pointer-events:auto;padding:0">
|
||||
<span style="display:block;padding:20px">Start</span>
|
||||
</button>
|
||||
</div>"#,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(result.passed, "{:?}", result.diagnostics);
|
||||
for viewport in REQUIRED_VIEWPORTS {
|
||||
let playtest = &result
|
||||
.viewport_playtests
|
||||
.iter()
|
||||
.find(|entry| entry.viewport == viewport)
|
||||
.expect("each viewport must have gameplay evidence")
|
||||
.result;
|
||||
assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics);
|
||||
assert_eq!(playtest.initial_sequence, Some(0));
|
||||
assert_eq!(playtest.final_sequence, Some(3));
|
||||
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
|
||||
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() {
|
||||
for (case, markup) in [
|
||||
(
|
||||
"inherited-none",
|
||||
r#"<div style="pointer-events:none"><button data-playtest-id="start">Start</button></div>"#,
|
||||
),
|
||||
(
|
||||
"own-none",
|
||||
r#"<button data-playtest-id="start" style="pointer-events:none">Start</button>"#,
|
||||
),
|
||||
(
|
||||
"own-none-child-auto",
|
||||
r#"<button data-playtest-id="start" style="pointer-events:none;padding:0"><span style="pointer-events:auto;display:block;padding:20px">Start</span></button>"#,
|
||||
),
|
||||
(
|
||||
"disabled",
|
||||
r#"<button data-playtest-id="start" disabled>Start</button>"#,
|
||||
),
|
||||
(
|
||||
"disabled-fieldset",
|
||||
r#"<fieldset disabled><button data-playtest-id="start">Start</button></fieldset>"#,
|
||||
),
|
||||
(
|
||||
"aria-disabled",
|
||||
r#"<button data-playtest-id="start" aria-disabled="true">Start</button>"#,
|
||||
),
|
||||
(
|
||||
"inert",
|
||||
r#"<div inert><button data-playtest-id="start" style="pointer-events:auto">Start</button></div>"#,
|
||||
),
|
||||
(
|
||||
"hidden",
|
||||
r#"<div style="display:none"><button data-playtest-id="start">Start</button></div>"#,
|
||||
),
|
||||
(
|
||||
"covered",
|
||||
r#"<div style="position:relative;width:max-content"><button data-playtest-id="start">Start</button><div style="position:absolute;inset:0;background:black"></div></div>"#,
|
||||
),
|
||||
] {
|
||||
let result = run_stable_generic_viewport_fixture(false, Some(markup)).await;
|
||||
assert!(!result.passed, "{case} must fail");
|
||||
for viewport in REQUIRED_VIEWPORTS {
|
||||
let playtest = &result
|
||||
.viewport_playtests
|
||||
.iter()
|
||||
.find(|entry| entry.viewport == viewport)
|
||||
.expect("each viewport must have gameplay evidence")
|
||||
.result;
|
||||
assert!(!playtest.passed, "{case} / {viewport:?} must fail");
|
||||
assert!(
|
||||
playtest
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|message| message.contains("固定试玩控件 start")),
|
||||
"{case} / {viewport:?}: {:?}",
|
||||
playtest.diagnostics
|
||||
);
|
||||
assert!(
|
||||
playtest.assertions.iter().any(|assertion| {
|
||||
assertion.name == "start-control-clicked" && !assertion.passed
|
||||
}),
|
||||
"{case} / {viewport:?} must be rejected before input"
|
||||
);
|
||||
if case == "own-none-child-auto" {
|
||||
assert!(
|
||||
playtest.diagnostics.iter().any(|message| {
|
||||
message == "固定试玩控件 start 处于 disabled 状态"
|
||||
}),
|
||||
"{viewport:?}: child hit must not enable the pointer-blocked target: {:?}",
|
||||
playtest.diagnostics
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_stable_generic_viewport_fixture(
|
||||
mobile_action_broken: bool,
|
||||
start_markup: Option<&str>,
|
||||
) -> BrowserValidationResult {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture(
|
||||
</body>
|
||||
</html>"#;
|
||||
let html = String::from_utf8_lossy(html)
|
||||
.replace(
|
||||
r#"<button type="button" data-playtest-id="start">Start</button>"#,
|
||||
start_markup
|
||||
.unwrap_or(r#"<button type="button" data-playtest-id="start">Start</button>"#),
|
||||
)
|
||||
.replace(
|
||||
"const mobileActionBroken = false;",
|
||||
if mobile_action_broken {
|
||||
|
||||
@@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner(
|
||||
model_id: String,
|
||||
is_default: bool,
|
||||
catalog_agent_mode: Option<String>,
|
||||
catalog_protocol: Option<String>,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
@@ -1098,13 +1099,34 @@ fn select_game_creator_model_inner(
|
||||
if let Some(agent_mode) = catalog_agent_mode {
|
||||
config.agent_mode = match agent_mode.trim() {
|
||||
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
|
||||
// `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
|
||||
"cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
|
||||
"cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
|
||||
value => return Err(format!("模型 Agent 模式无效:{value}")),
|
||||
};
|
||||
}
|
||||
// 协议与执行器正交,但合法组合是封闭的;缺省按执行器推导。
|
||||
let protocol = match catalog_protocol.as_deref().map(str::trim) {
|
||||
Some("openai_responses") => "openai_responses",
|
||||
Some("openai_chat") => "openai_chat",
|
||||
Some("anthropic") => "anthropic",
|
||||
Some("") | None => {
|
||||
if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
|
||||
"anthropic"
|
||||
} else {
|
||||
"openai_responses"
|
||||
}
|
||||
}
|
||||
Some(value) => return Err(format!("模型协议无效:{value}")),
|
||||
};
|
||||
let claude = config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI;
|
||||
if claude != (protocol == "anthropic") {
|
||||
return Err(format!(
|
||||
"执行器与协议组合无效:{} 不支持 {protocol}",
|
||||
config.agent_mode
|
||||
));
|
||||
}
|
||||
config.selected_model_id = model_id;
|
||||
config.selected_model_is_default = is_default;
|
||||
config.selected_model_protocol = Some(protocol.to_string());
|
||||
persist_game_creator_app_config(config, overlays, true)
|
||||
}
|
||||
|
||||
@@ -1113,8 +1135,9 @@ pub(crate) fn select_game_creator_model(
|
||||
model_id: String,
|
||||
is_default: bool,
|
||||
agent_mode: Option<String>,
|
||||
protocol: Option<String>,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
select_game_creator_model_inner(model_id, is_default, agent_mode)
|
||||
select_game_creator_model_inner(model_id, is_default, agent_mode, protocol)
|
||||
}
|
||||
|
||||
fn persist_game_creator_app_config(
|
||||
@@ -1981,6 +2004,126 @@ mod agent_asset_import_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn account_import_contention_downloads(
|
||||
) -> Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)> {
|
||||
vec![(
|
||||
AgentEditorAssetRecord {
|
||||
asset_id: "account-contention".into(),
|
||||
origin: AgentEditorAssetOrigin::AccountLibrary,
|
||||
canvas_project_id: None,
|
||||
folder_id: None,
|
||||
folder_label: None,
|
||||
label: "账户争用测试".into(),
|
||||
object_key: None,
|
||||
image_src: None,
|
||||
asset_object_id: None,
|
||||
asset_kind: Some("image".into()),
|
||||
source_type: None,
|
||||
width: None,
|
||||
height: None,
|
||||
size_bytes: None,
|
||||
},
|
||||
"image/png".into(),
|
||||
"assets/uploads/account-contention.png".into(),
|
||||
tiny_png(),
|
||||
)]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_imports_wait_for_conversation_without_blocking_or_losing_updates() {
|
||||
let project = crate::tests::canonical_test_tempdir("import-contention-");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "import-contention", "导入争用").unwrap();
|
||||
fs::write(root.join("game/local.png"), tiny_png()).unwrap();
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
let local_paths = vec!["game/local.png".to_string()];
|
||||
let (local, account) = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
async {
|
||||
tokio::try_join!(
|
||||
import_local_project_assets_for_agent_in_blocking_pool(
|
||||
root,
|
||||
&local_paths,
|
||||
None,
|
||||
),
|
||||
commit_account_editor_asset_downloads_in_blocking_pool(
|
||||
root,
|
||||
account_import_contention_downloads(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let manifest = read_existing_manifest_for_project(root).unwrap();
|
||||
for result in [&local, &account] {
|
||||
assert_eq!(result.assets.len(), 1);
|
||||
let asset = &result.assets[0];
|
||||
assert!(manifest.assets.iter().any(|entry| entry.id == asset.id));
|
||||
assert_eq!(fs::read(root.join(&asset.local_path)).unwrap(), tiny_png());
|
||||
}
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision,
|
||||
before + 2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_asset_commit_rechecks_session_after_conversation_wait() {
|
||||
let _session_guard = install_test_platform_session(
|
||||
"import-owner-a",
|
||||
"import-token-a",
|
||||
"https://import.example.test",
|
||||
);
|
||||
let session = current_platform_session().unwrap();
|
||||
let project = crate::tests::canonical_test_tempdir("import-session-contention-");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(root, "import-session-contention", "导入身份争用").unwrap();
|
||||
let before = fs::read(root.join(".agent/manifest.json")).unwrap();
|
||||
let before_revision = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
let generation = session.identity_generation + 1;
|
||||
let error = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
commit_account_editor_asset_downloads_in_blocking_pool(
|
||||
root,
|
||||
account_import_contention_downloads(),
|
||||
None,
|
||||
Some(session),
|
||||
),
|
||||
|| {
|
||||
install_platform_session(
|
||||
"import-owner-b",
|
||||
"import-token-b",
|
||||
"https://import.example.test",
|
||||
generation,
|
||||
generation,
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("旧账号不能在等待后提交");
|
||||
assert!(error.contains("authentication-required"), "{error}");
|
||||
assert_eq!(fs::read(root.join(".agent/manifest.json")).unwrap(), before);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision,
|
||||
before_revision
|
||||
);
|
||||
assert!(!root.join("assets/uploads/account-contention.png").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
|
||||
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
|
||||
@@ -3237,6 +3380,25 @@ pub(crate) fn import_local_project_assets_for_agent(
|
||||
import_local_project_assets_for_agent_with_write_permit(root, relative_paths, None)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_local_project_assets_for_agent_in_blocking_pool(
|
||||
root: &Path,
|
||||
relative_paths: &[String],
|
||||
write_permit: Option<&crate::agent::WritePermit>,
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
let root = root.to_path_buf();
|
||||
let relative_paths = relative_paths.to_vec();
|
||||
let write_permit = write_permit.cloned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
import_local_project_assets_for_agent_with_write_permit(
|
||||
&root,
|
||||
&relative_paths,
|
||||
write_permit.as_ref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("本地素材导入任务未返回:{error}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn import_local_project_assets_for_agent_with_write_permit(
|
||||
root: &Path,
|
||||
relative_paths: &[String],
|
||||
@@ -3301,7 +3463,11 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit(
|
||||
));
|
||||
}
|
||||
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"canvas.asset_import",
|
||||
)?;
|
||||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||||
let commit = || {
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
let mut imported = Vec::with_capacity(inputs.len());
|
||||
@@ -3497,20 +3663,50 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit(
|
||||
}
|
||||
|
||||
access.validate_frozen_session()?;
|
||||
let _platform_session_lease = frozen_session
|
||||
.as_ref()
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
// 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。
|
||||
commit_account_editor_asset_downloads(root, downloads, write_permit)
|
||||
commit_account_editor_asset_downloads_in_blocking_pool(
|
||||
root,
|
||||
downloads,
|
||||
write_permit,
|
||||
frozen_session,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_account_editor_asset_downloads_in_blocking_pool(
|
||||
root: &Path,
|
||||
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)>,
|
||||
write_permit: Option<&crate::agent::WritePermit>,
|
||||
frozen_session: Option<PlatformSessionSnapshot>,
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
let root = root.to_path_buf();
|
||||
let write_permit = write_permit.cloned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
commit_account_editor_asset_downloads(
|
||||
&root,
|
||||
downloads,
|
||||
write_permit.as_ref(),
|
||||
frozen_session.as_ref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("账户素材提交任务未返回:{error}"))?
|
||||
}
|
||||
|
||||
fn commit_account_editor_asset_downloads(
|
||||
root: &Path,
|
||||
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)>,
|
||||
write_permit: Option<&crate::agent::WritePermit>,
|
||||
frozen_session: Option<&PlatformSessionSnapshot>,
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"canvas.asset_import",
|
||||
)?;
|
||||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||||
// 等待后才取得身份租约;等待期间允许换号,提交时拒绝旧账号结果。
|
||||
let _platform_session_lease = frozen_session
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
let commit = || {
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
let mut imported = Vec::with_capacity(downloads.len());
|
||||
@@ -3639,6 +3835,7 @@ pub(crate) fn commit_account_asset_for_write_permit_test(
|
||||
bytes.to_vec(),
|
||||
)],
|
||||
Some(write_permit),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -583,7 +583,12 @@ fn check_game_creator_codex_app_server_available() -> Result<(), String> {
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
|
||||
.map_err(|error| {
|
||||
// 进程起不来(可执行文件缺失、路径错误、权限不足或被安全软件拦截)与
|
||||
// 「不支持 app-server 子命令」是两码事:报成后者会让用户去升级 CLI,
|
||||
// 升级完仍是同一个错。这里保留真实 OS 错误。
|
||||
format!("Codex CLI 启动失败:{error}")
|
||||
})?;
|
||||
if !output.status.success()
|
||||
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
|
||||
{
|
||||
@@ -4037,6 +4042,9 @@ fn merge_game_creator_config_content(
|
||||
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
|
||||
config.selected_model_is_default = selected_model_is_default;
|
||||
}
|
||||
if let Some(selected_model_protocol) = file_config.selected_model_protocol {
|
||||
config.selected_model_protocol = Some(selected_model_protocol);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,46 @@ async fn host_npm(
|
||||
.map_err(str::to_string)
|
||||
}
|
||||
|
||||
/// 浏览器验证失败的宿主错误码。
|
||||
///
|
||||
/// 验证层会为不同原因返回不同文本:没有可用浏览器、启动失败或启动超时、宿主取消、
|
||||
/// 浏览器收束未确认、证据写入失败、输入或页面校验失败。把它们统一压成「浏览器有问题」,
|
||||
/// 用户和值班人员都会朝浏览器方向排查却查不到原因,所以这里按真实原因分流;
|
||||
/// 未识别的原因落回专用兜底码 `web-preflight-unclassified`,不再借用某个具体子系统。
|
||||
fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
if error.contains("未发现可用的") {
|
||||
"web-preflight-browser-missing"
|
||||
} else if error.contains("启动浏览器超时") {
|
||||
"web-preflight-browser-launch-timeout"
|
||||
} else if error.contains("启动浏览器失败") {
|
||||
"web-preflight-browser-launch-failed"
|
||||
} else if error.contains("宿主已停止本轮浏览器验证") {
|
||||
"web-preflight-cancelled"
|
||||
} else if error.contains("browser-cleanup")
|
||||
|| error.contains("browser-tree-reap")
|
||||
|| error.contains("browser-version-invalid")
|
||||
{
|
||||
"web-preflight-browser-cleanup-failed"
|
||||
} else if error.contains("浏览器临时") || error.contains("浏览器配置") {
|
||||
"web-preflight-browser-environment-failed"
|
||||
} else if error.contains("evidenceRoot")
|
||||
|| error.contains("证据")
|
||||
|| error.contains("浏览器验证报告")
|
||||
|| error.contains("浏览器验证截图")
|
||||
{
|
||||
"web-preflight-evidence-failed"
|
||||
} else if error.contains("预览 URL")
|
||||
|| error.contains("viewports")
|
||||
|| error.contains("settleMs")
|
||||
{
|
||||
"web-preflight-input-invalid"
|
||||
} else if error.contains("页面导航失败") || error.contains("截图不是有效 PNG") {
|
||||
"web-preflight-page-check-failed"
|
||||
} else {
|
||||
"web-preflight-unclassified"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let started = Instant::now();
|
||||
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
|
||||
@@ -322,7 +362,7 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
|
||||
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
|
||||
}).await;
|
||||
let result = validation.map_err(|_| "web-preflight-browser-failed")?;
|
||||
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
|
||||
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
|
||||
let mut pngs = Vec::new();
|
||||
for viewport in result.viewport_results {
|
||||
@@ -382,6 +422,24 @@ fn claim_preparation(root: &Path) -> Result<Option<(ScaffoldReceipt, Preparation
|
||||
Ok(Some((receipt, claim)))
|
||||
}
|
||||
|
||||
/// 依赖准备收据里的失败码。
|
||||
///
|
||||
/// 收据只存机器码,但也不能把任何失败都写成同一个通用码——那等于把这条诊断线索作废。
|
||||
/// 这里从宿主错误里取出真正的码(`Web 新项目依赖准备失败:web-build-timeout;…` 取
|
||||
/// `web-build-timeout`,裸码原样保留),只有确实拿不到码时才落回专用兜底。
|
||||
fn scaffold_failure_code(error: &str) -> String {
|
||||
let candidate = error
|
||||
.split(|ch: char| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'))
|
||||
.find(|token| {
|
||||
token.len() >= 4
|
||||
&& token.chars().any(|ch| ch.is_ascii_lowercase())
|
||||
&& token.chars().any(|ch| ch == '-')
|
||||
});
|
||||
candidate
|
||||
.unwrap_or("web-scaffold-preparation-failed")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_new_web_project_at(
|
||||
root: &Path,
|
||||
creation_type: Option<&str>,
|
||||
@@ -457,7 +515,7 @@ pub(crate) async fn prepare_new_web_project_at(
|
||||
receipt.last_failure_code = result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|_| "web-scaffold-preparation-failed".into());
|
||||
.map(|error| scaffold_failure_code(error));
|
||||
if result.is_ok() {
|
||||
let entry = fs::read(ordinary_path(root, "game/dist/index.html")?)
|
||||
.map_err(|_| "web-scaffold-dist-missing")?;
|
||||
@@ -474,6 +532,78 @@ pub(crate) async fn prepare_new_web_project_at(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn browser_validation_failures_keep_their_own_reason_codes() {
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"未发现可用的 Google Chrome、Chromium 或 Microsoft Edge"
|
||||
),
|
||||
"web-preflight-browser-missing"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("启动浏览器超时"),
|
||||
"web-preflight-browser-launch-timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("启动浏览器失败:2"),
|
||||
"web-preflight-browser-launch-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
|
||||
"web-preflight-cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
|
||||
),
|
||||
"web-preflight-browser-cleanup-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
|
||||
"web-preflight-browser-environment-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("evidenceRoot 必须是真实目录且不能是符号链接"),
|
||||
"web-preflight-evidence-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("只允许带显式端口的 http://127.0.0.1 预览 URL"),
|
||||
"web-preflight-input-invalid"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("desktop 页面导航失败:timeout"),
|
||||
"web-preflight-page-check-failed"
|
||||
);
|
||||
// 未识别的原因必须落回专用兜底码,不能借上面任何一个具体子系统码。
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("某种尚未识别的新失败"),
|
||||
"web-preflight-unclassified"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_receipt_keeps_the_real_failure_code() {
|
||||
assert_eq!(
|
||||
scaffold_failure_code("web-build-timeout"),
|
||||
"web-build-timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
scaffold_failure_code("Web 新项目依赖准备失败:web-build-command-failed;尚未启动生成"),
|
||||
"web-build-command-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
scaffold_failure_code(
|
||||
"Web 游戏环境预检未通过:web-preflight-browser-missing;尚未启动生成"
|
||||
),
|
||||
"web-preflight-browser-missing"
|
||||
);
|
||||
// 拿不到机器码时才使用专用兜底。
|
||||
assert_eq!(
|
||||
scaffold_failure_code("没有机器码的中文说明"),
|
||||
"web-scaffold-preparation-failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_new_unmodified_scaffolds_receive_a_preparation_receipt() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -16,6 +16,10 @@ fn default_codex_agent_mode() -> String {
|
||||
"codex".to_string()
|
||||
}
|
||||
|
||||
fn default_model_protocol() -> String {
|
||||
"openai_responses".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelSummary {
|
||||
@@ -23,6 +27,9 @@ struct WireModelSummary {
|
||||
display_name: String,
|
||||
#[serde(default = "default_codex_agent_mode")]
|
||||
agent_mode: String,
|
||||
/// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。
|
||||
#[serde(default = "default_model_protocol")]
|
||||
protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -56,6 +63,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
|
||||
id: model.id,
|
||||
display_name: model.display_name,
|
||||
agent_mode: model.agent_mode,
|
||||
protocol: model.protocol,
|
||||
})
|
||||
.collect(),
|
||||
revision: catalog.revision,
|
||||
|
||||
@@ -812,6 +812,9 @@ struct GameCreatorAppConfigFile {
|
||||
selected_model_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_is_default: Option<bool>,
|
||||
/// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_protocol: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -873,6 +876,9 @@ struct GameCreatorAppConfig {
|
||||
selected_model_id: String,
|
||||
#[serde(default)]
|
||||
selected_model_is_default: bool,
|
||||
/// 所选模型的上游线协议;缺省按执行器推导。
|
||||
#[serde(default)]
|
||||
selected_model_protocol: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -1384,6 +1390,7 @@ impl Default for GameCreatorAppConfig {
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
selected_model_id: String::new(),
|
||||
selected_model_is_default: false,
|
||||
selected_model_protocol: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod listing_tests;
|
||||
|
||||
/**
|
||||
* Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。
|
||||
*
|
||||
@@ -18,6 +21,24 @@ fn is_engine_generated_root_directory(relative_path: &str) -> bool {
|
||||
|
||||
pub(crate) fn list_local_project_files_at(
|
||||
root: &Path,
|
||||
) -> Result<ListLocalProjectFilesResult, String> {
|
||||
list_local_project_files_scoped_at(root, None, |_| false)
|
||||
}
|
||||
|
||||
/// 在读取元数据前裁剪范围;调用方的可见性规则不改变通用文件树。
|
||||
pub(crate) fn list_local_project_files_scoped_at(
|
||||
root: &Path,
|
||||
scope: Option<&str>,
|
||||
exclude: impl Fn(&str) -> bool,
|
||||
) -> Result<ListLocalProjectFilesResult, String> {
|
||||
list_local_project_files_with_metadata(root, scope, exclude, |path| fs::symlink_metadata(path))
|
||||
}
|
||||
|
||||
fn list_local_project_files_with_metadata(
|
||||
root: &Path,
|
||||
scope: Option<&str>,
|
||||
exclude: impl Fn(&str) -> bool,
|
||||
mut read_metadata: impl FnMut(&Path) -> std::io::Result<fs::Metadata>,
|
||||
) -> Result<ListLocalProjectFilesResult, String> {
|
||||
validate_project_root(root)?;
|
||||
if !root.exists() {
|
||||
@@ -37,28 +58,58 @@ pub(crate) fn list_local_project_files_at(
|
||||
*/
|
||||
let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some();
|
||||
let is_unity_project = discover_local_unity_project_root(root)?.is_some();
|
||||
let scope_prefix = scope.map(|path| format!("{path}/"));
|
||||
let mut files = Vec::new();
|
||||
let mut dirs = vec![root.to_path_buf()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir)
|
||||
.map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))?
|
||||
{
|
||||
// 目录排队后可能消失或被换成链接,进入前重新核验。
|
||||
let metadata = match read_metadata(&dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(format!("读取文件元数据失败:{}: {error}", dir.display())),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||||
continue;
|
||||
}
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(format!("读取项目目录失败:{}: {error}", dir.display())),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry =
|
||||
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)
|
||||
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
|
||||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||||
let relative_path = relative_project_path(root, &path)?;
|
||||
let in_scope = scope.is_none_or(|scope| {
|
||||
relative_path == scope
|
||||
|| scope_prefix
|
||||
.as_ref()
|
||||
.is_some_and(|prefix| relative_path.starts_with(prefix))
|
||||
});
|
||||
let scope_ancestor =
|
||||
scope.is_some_and(|scope| scope.starts_with(&format!("{relative_path}/")));
|
||||
if (!in_scope && !scope_ancestor) || exclude(&relative_path) {
|
||||
continue;
|
||||
}
|
||||
let file_type = metadata.file_type();
|
||||
let relative_path = relative_project_path(root, &path)?;
|
||||
if is_agent_runtime_private_control_path(&relative_path)
|
||||
|| is_agent_checkpoint_control_path(&relative_path)
|
||||
|| is_agent_workbench_control_path(&relative_path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let metadata = match read_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
// read_dir 只观察名字,不保证随后还能读取该条目。
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
return Err(format!("读取文件元数据失败:{}: {error}", path.display()))
|
||||
}
|
||||
};
|
||||
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
|
||||
continue;
|
||||
}
|
||||
let file_type = metadata.file_type();
|
||||
let modified_at = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
@@ -80,14 +131,16 @@ pub(crate) fn list_local_project_files_at(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
files.push(LocalProjectFileEntry {
|
||||
path: relative_path,
|
||||
kind: "directory".to_string(),
|
||||
size: 0,
|
||||
modified_at,
|
||||
});
|
||||
if in_scope {
|
||||
files.push(LocalProjectFileEntry {
|
||||
path: relative_path,
|
||||
kind: "directory".to_string(),
|
||||
size: 0,
|
||||
modified_at,
|
||||
});
|
||||
}
|
||||
dirs.push(path);
|
||||
} else if file_type.is_file() {
|
||||
} else if file_type.is_file() && in_scope {
|
||||
let size = metadata.len();
|
||||
files.push(LocalProjectFileEntry {
|
||||
path: relative_path,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
use super::*;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn project_file_listing_survives_lock_release_between_enumeration_and_metadata() {
|
||||
for scope in [None, Some("assets")] {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path().canonicalize().unwrap();
|
||||
fs::create_dir(root.join("assets")).unwrap();
|
||||
fs::write(root.join("assets/hero.png"), b"image").unwrap();
|
||||
let (ready_tx, ready_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let (released_tx, released_rx) = mpsc::channel();
|
||||
let timeout = Duration::from_secs(10);
|
||||
|
||||
std::thread::scope(|threads| {
|
||||
let owner_root = &root;
|
||||
let owner = threads.spawn(move || {
|
||||
let lock = acquire_project_write_lock(owner_root, "file-listing-race").unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
release_rx.recv_timeout(timeout).unwrap();
|
||||
drop(lock);
|
||||
released_tx.send(()).unwrap();
|
||||
});
|
||||
ready_rx.recv_timeout(timeout).unwrap();
|
||||
let mut observed_gap = false;
|
||||
let listed = list_local_project_files_with_metadata(
|
||||
&root,
|
||||
scope,
|
||||
|_| false,
|
||||
|path| {
|
||||
if scope.is_some() {
|
||||
assert!(
|
||||
!path.starts_with(root.join(".agent")),
|
||||
"定向列举不得检查锁目录"
|
||||
);
|
||||
}
|
||||
let trigger = if scope.is_some() {
|
||||
root.join("assets")
|
||||
} else {
|
||||
root.join(PROJECT_WRITE_LOCK_PATH)
|
||||
};
|
||||
if path == trigger && !observed_gap {
|
||||
// 全量列举复现锁的 TOCTOU;定向列举在访问 assets 时同时释放锁。
|
||||
release_tx.send(()).unwrap();
|
||||
released_rx.recv_timeout(timeout).unwrap();
|
||||
let result = fs::symlink_metadata(path);
|
||||
if scope.is_none() {
|
||||
assert_eq!(result.as_ref().unwrap_err().kind(), ErrorKind::NotFound);
|
||||
}
|
||||
observed_gap = true;
|
||||
return result;
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
owner.join().unwrap();
|
||||
assert!(observed_gap, "夹具必须在列举期间释放真实锁");
|
||||
assert!(listed
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "assets/hero.png"));
|
||||
assert!(!listed
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == PROJECT_WRITE_LOCK_PATH));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_file_listing_prunes_scope_and_private_paths_before_metadata() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
for path in [
|
||||
"assets/nested",
|
||||
"assets-other",
|
||||
".agent/runtime",
|
||||
".agent/checkpoints",
|
||||
".agent/workbench",
|
||||
] {
|
||||
fs::create_dir_all(root.join(path)).unwrap();
|
||||
}
|
||||
fs::write(root.join("assets/nested/hero.png"), b"image").unwrap();
|
||||
fs::write(root.join("assets/other.png"), b"other").unwrap();
|
||||
fs::write(root.join(".env"), b"test-only").unwrap();
|
||||
for scope in ["assets/nested", "assets/nested/hero.png"] {
|
||||
let listed = list_local_project_files_with_metadata(
|
||||
root,
|
||||
Some(scope),
|
||||
|_| false,
|
||||
|path| {
|
||||
if path != root
|
||||
&& path != root.join("assets")
|
||||
&& !path.starts_with(root.join("assets/nested"))
|
||||
{
|
||||
panic!("不应读取范围外元数据:{}", path.display());
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
listed
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.kind == "file")
|
||||
.map(|file| file.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["assets/nested/hero.png"]
|
||||
);
|
||||
}
|
||||
let listed = list_local_project_files_with_metadata(
|
||||
root,
|
||||
None,
|
||||
|path| path == ".env",
|
||||
|path| {
|
||||
for excluded in [
|
||||
".env",
|
||||
".agent/runtime",
|
||||
".agent/checkpoints",
|
||||
".agent/workbench",
|
||||
] {
|
||||
assert!(
|
||||
!path.starts_with(root.join(excluded)),
|
||||
"过滤必须早于元数据读取"
|
||||
);
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
listed.files.iter().any(|file| file.path == ".agent"),
|
||||
"通用文件树仍可见普通控制面目录"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_file_listing_skips_disappearing_entries_but_preserves_io_errors() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
fs::create_dir(root.join("assets")).unwrap();
|
||||
let target = root.join("assets/hero.png");
|
||||
fs::write(&target, b"image").unwrap();
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::Other] {
|
||||
let error = list_local_project_files_with_metadata(
|
||||
root,
|
||||
Some("assets"),
|
||||
|_| false,
|
||||
|path| {
|
||||
if path == target {
|
||||
return Err(Error::new(kind, "listing-error-sentinel"));
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.contains("hero.png") && error.contains("listing-error-sentinel"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
let listed = list_local_project_files_with_metadata(
|
||||
root,
|
||||
Some("assets"),
|
||||
|_| false,
|
||||
|path| {
|
||||
if path == target {
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!listed.files.iter().any(|file| file.kind == "file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_file_listing_handles_queued_directory_disappearance_and_type_change() {
|
||||
for change in ["before-metadata", "before-read-dir", "replace-with-file"] {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
let target = root.join("assets");
|
||||
fs::create_dir(&target).unwrap();
|
||||
let mut visits = 0;
|
||||
let result = list_local_project_files_with_metadata(
|
||||
root,
|
||||
Some("assets"),
|
||||
|_| false,
|
||||
|path| {
|
||||
if path == target {
|
||||
visits += 1;
|
||||
if visits == 2 {
|
||||
let previous = fs::symlink_metadata(path);
|
||||
fs::remove_dir(path).unwrap();
|
||||
if change == "before-read-dir" {
|
||||
return previous;
|
||||
}
|
||||
if change == "replace-with-file" {
|
||||
fs::write(path, b"not a directory").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
);
|
||||
assert_eq!(visits, 2);
|
||||
if change == "replace-with-file" {
|
||||
assert!(result.unwrap_err().contains("读取项目目录失败"));
|
||||
} else {
|
||||
assert!(!result.unwrap().files.iter().any(|file| file.kind == "file"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_file_listing_reports_target_permission_errors_but_ignores_unrelated_ones() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
let blocked = root.join("blocked");
|
||||
fs::create_dir(&blocked).unwrap();
|
||||
fs::create_dir(root.join("assets")).unwrap();
|
||||
fs::write(root.join("assets/hero.png"), b"image").unwrap();
|
||||
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
// 先恢复权限再断言,避免失败时影响临时目录回收。
|
||||
let scoped = list_local_project_files_scoped_at(root, Some("assets"), |_| false);
|
||||
let blocked_result = list_local_project_files_scoped_at(root, Some("blocked"), |_| false);
|
||||
let permission_denied = fs::read_dir(&blocked).is_err();
|
||||
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(scoped
|
||||
.unwrap()
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "assets/hero.png"));
|
||||
// root / CAP_DAC_OVERRIDE 环境不能制造真实 EACCES;注入用例仍覆盖错误分支。
|
||||
if permission_denied {
|
||||
let error = blocked_result.unwrap_err();
|
||||
assert!(
|
||||
error.contains("读取项目目录失败") && error.contains("blocked"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_file_listing_never_enters_linked_or_replaced_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let root = temporary.path();
|
||||
fs::write(outside.path().join("private.txt"), b"outside").unwrap();
|
||||
symlink(outside.path(), root.join("linked")).unwrap();
|
||||
fs::create_dir(root.join("assets")).unwrap();
|
||||
let mut visits = 0;
|
||||
let listed = list_local_project_files_with_metadata(
|
||||
root,
|
||||
None,
|
||||
|_| false,
|
||||
|path| {
|
||||
if path == root.join("assets") {
|
||||
visits += 1;
|
||||
if visits == 2 {
|
||||
fs::remove_dir(path).unwrap();
|
||||
symlink(outside.path(), path).unwrap();
|
||||
}
|
||||
}
|
||||
fs::symlink_metadata(path)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(visits, 2);
|
||||
assert!(!listed.files.iter().any(|file| file.kind == "file"));
|
||||
for scope in ["linked", "linked/private.txt", "assets/private.txt"] {
|
||||
assert!(
|
||||
list_local_project_files_scoped_at(root, Some(scope), |_| false)
|
||||
.unwrap()
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4302,7 +4302,8 @@ fn commit_resource_edit_asset_internal(
|
||||
reference_resource_ids: vec![source.canonical_resource_id.clone()],
|
||||
},
|
||||
};
|
||||
let _project_lock = acquire_project_write_lock(root, "resource.edit")?;
|
||||
let _project_lock =
|
||||
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?;
|
||||
let mut manifest = read_existing_manifest_for_project(root)?;
|
||||
if manifest.project_id != input.expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
@@ -4615,6 +4616,10 @@ fn commit_resource_edit_asset_with_frozen_platform_session(
|
||||
ledger: &mut ResourceEditLedger,
|
||||
platform_session: Option<&PlatformSessionSnapshot>,
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
// 项目锁先于平台身份租约,避免等待时挡住换号或与其它提交形成锁顺序反转。
|
||||
// 内层提交在同一线程复用项目锁,整个事务不能跨 await 或线程迁移。
|
||||
let _project_lock =
|
||||
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?;
|
||||
with_frozen_resource_edit_platform_session(platform_session, || {
|
||||
commit_resource_edit_asset(root, input, source, prompt, asset_name, ledger)
|
||||
})
|
||||
@@ -4696,7 +4701,10 @@ fn commit_resource_edit_version(
|
||||
ledger: &mut ResourceEditLedger,
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
let version_id = format!("edit-{}", input.operation_id);
|
||||
let _project_lock = acquire_project_write_lock(root, "resource.edit.version")?;
|
||||
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"resource.edit.version",
|
||||
)?;
|
||||
let mut manifest = read_existing_manifest_for_project(root)?;
|
||||
if manifest.project_id != input.expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
@@ -5518,7 +5526,7 @@ pub(crate) async fn derive_local_project_resource_at(
|
||||
let root = Path::new(input.project_path.trim());
|
||||
validate_project_root(root)?;
|
||||
let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?;
|
||||
let _operation_guard = operation_lock.lock().await;
|
||||
let operation_guard = operation_lock.lock_owned().await;
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
if manifest.project_id != input.expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
@@ -5652,23 +5660,36 @@ pub(crate) async fn derive_local_project_resource_at(
|
||||
ledger
|
||||
}
|
||||
};
|
||||
if ledger.phase == ResourceEditLedgerPhase::Committed {
|
||||
if ledger.phase == ResourceEditLedgerPhase::Committed
|
||||
|| input.edit_kind == LocalProjectResourceEditKind::Version
|
||||
{
|
||||
let project_lock = resource_edit_project_mutation_lock(root)?;
|
||||
let _project_guard = project_lock.lock().await;
|
||||
let _project_write_lock = acquire_project_write_lock(root, "resource.edit.cleanup")?;
|
||||
cleanup_committed_resource_edit_staging(root, &mut ledger)?;
|
||||
return committed_resource_edit_result(
|
||||
root,
|
||||
&input,
|
||||
&source.canonical_resource_id,
|
||||
ledger.result_asset_id.as_deref(),
|
||||
ledger.result_version_id.as_deref(),
|
||||
);
|
||||
}
|
||||
if input.edit_kind == LocalProjectResourceEditKind::Version {
|
||||
let project_lock = resource_edit_project_mutation_lock(root)?;
|
||||
let _project_guard = project_lock.lock().await;
|
||||
return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger);
|
||||
let project_guard = project_lock.lock_owned().await;
|
||||
let root = root.to_path_buf();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
// 调用方取消等待时,互斥仍由正在运行的提交持有。
|
||||
let _operation_guard = operation_guard;
|
||||
let _project_guard = project_guard;
|
||||
if ledger.phase == ResourceEditLedgerPhase::Committed {
|
||||
let _project_write_lock =
|
||||
acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
&root,
|
||||
"resource.edit.cleanup",
|
||||
)?;
|
||||
cleanup_committed_resource_edit_staging(&root, &mut ledger)?;
|
||||
committed_resource_edit_result(
|
||||
&root,
|
||||
&input,
|
||||
&source.canonical_resource_id,
|
||||
ledger.result_asset_id.as_deref(),
|
||||
ledger.result_version_id.as_deref(),
|
||||
)
|
||||
} else {
|
||||
commit_resource_edit_version(&root, &input, &source, &prompt, &mut ledger)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("资源编辑提交任务未返回:{error}"))?;
|
||||
}
|
||||
let remote_credentials = if input.edit_kind.is_remote_media()
|
||||
&& ledger.phase != ResourceEditLedgerPhase::MediaDownloaded
|
||||
@@ -5775,16 +5796,28 @@ pub(crate) async fn derive_local_project_resource_at(
|
||||
}
|
||||
}
|
||||
let project_lock = resource_edit_project_mutation_lock(root)?;
|
||||
let _project_guard = project_lock.lock().await;
|
||||
let result = commit_resource_edit_asset_with_frozen_platform_session(
|
||||
root,
|
||||
&input,
|
||||
&source,
|
||||
&prompt,
|
||||
&asset_name,
|
||||
&mut ledger,
|
||||
frozen_platform_session.as_ref(),
|
||||
)?;
|
||||
let project_guard = project_lock.lock_owned().await;
|
||||
let commit_root = root.to_path_buf();
|
||||
let commit_input = input.clone();
|
||||
let commit_session = frozen_platform_session.clone();
|
||||
let (result, ledger, _operation_guard, _project_guard) =
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let operation_guard = operation_guard;
|
||||
let project_guard = project_guard;
|
||||
let result = commit_resource_edit_asset_with_frozen_platform_session(
|
||||
&commit_root,
|
||||
&commit_input,
|
||||
&source,
|
||||
&prompt,
|
||||
&asset_name,
|
||||
&mut ledger,
|
||||
commit_session.as_ref(),
|
||||
)?;
|
||||
// 正常返回仍覆盖后续 binding 写入;取消等待则在阻塞任务结束后释放。
|
||||
Ok::<_, String>((result, ledger, operation_guard, project_guard))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("资源编辑提交任务未返回:{error}"))??;
|
||||
if let (Some((api_base_url, api_key, _)), Some(principal)) =
|
||||
(remote_credentials.as_ref(), binding_principal.as_ref())
|
||||
{
|
||||
@@ -6462,7 +6495,7 @@ mod tests {
|
||||
drop(other_project_guard);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn project_mutation_lock_serializes_independent_asset_commits_without_lost_updates() {
|
||||
let directory = tempfile::tempdir().expect("create concurrent commit fixture");
|
||||
let root = directory.path();
|
||||
@@ -6513,14 +6546,31 @@ mod tests {
|
||||
requests.push(request);
|
||||
}
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
derive_local_project_resource_at(requests[0].clone()),
|
||||
derive_local_project_resource_at(requests[1].clone())
|
||||
);
|
||||
first.expect("commit first operation");
|
||||
second.expect("commit second operation");
|
||||
fs::write(root.join("game/parallel.md"), b"# Imported").unwrap();
|
||||
let local_paths = vec!["game/parallel.md".to_string()];
|
||||
let (_, _, imported) = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
async {
|
||||
tokio::try_join!(
|
||||
derive_local_project_resource_at(requests[0].clone()),
|
||||
derive_local_project_resource_at(requests[1].clone()),
|
||||
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
|
||||
root,
|
||||
&local_paths,
|
||||
None,
|
||||
),
|
||||
)
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.expect("并行导入和资源提交必须等待后成功");
|
||||
|
||||
let committed = read_existing_manifest_for_project(root).expect("read committed manifest");
|
||||
assert!(committed
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.id == imported.assets[0].id));
|
||||
for request in &requests {
|
||||
assert_eq!(
|
||||
committed
|
||||
@@ -6535,10 +6585,185 @@ mod tests {
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read committed revision")
|
||||
.revision,
|
||||
2
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
fn staged_background_removal_for_contention(root: &Path) -> DeriveLocalProjectResourceInput {
|
||||
init_local_game_project_at(root, PROJECT_ID, "资源提交争用").unwrap();
|
||||
let uploaded =
|
||||
upload_local_asset_at(root, "source.png", "image/png", &resource_editor_test_png())
|
||||
.unwrap();
|
||||
let manifest = read_existing_manifest_for_project(root).unwrap();
|
||||
let mut request = input(
|
||||
root,
|
||||
Uuid::new_v4().to_string(),
|
||||
LocalProjectResourceEditKind::BackgroundRemoval,
|
||||
format!("asset:{}", uploaded.id),
|
||||
);
|
||||
request.source_asset_id = Some(uploaded.id);
|
||||
request.source_path = Some(uploaded.local_path);
|
||||
request.source_media_type = Some("image/png".into());
|
||||
let source = resolve_resource_edit_source(root, &manifest, &request).unwrap();
|
||||
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
|
||||
ledger.request_fingerprint = resource_edit_request_fingerprint(
|
||||
&request,
|
||||
&source,
|
||||
&request.prompt,
|
||||
&request.asset_name,
|
||||
)
|
||||
.unwrap();
|
||||
ledger.remote_operation_id = Some("completed-background-removal".into());
|
||||
ledger.remote_object_key = Some("generated/completed.png".into());
|
||||
ledger.staged_media_type = Some("image/png".into());
|
||||
ledger.staged_extension = Some("png".into());
|
||||
write_resource_edit_ledger(root, &ledger).unwrap();
|
||||
write_resource_edit_staging(root, &request.operation_id, &resource_editor_test_png())
|
||||
.unwrap();
|
||||
request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloaded_resource_commit_and_replay_wait_without_new_remote_work() {
|
||||
let _session_guard = clear_test_platform_session();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path();
|
||||
let request = staged_background_removal_for_contention(root);
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
// 不提供远端凭据:下载完成后的提交与重放必须只使用原账本和 staging。
|
||||
let result = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
derive_local_project_resource_at(request.clone()),
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.operation_id, request.operation_id);
|
||||
assert_eq!(result.committed_project_revision, before + 1);
|
||||
let ledger = read_resource_edit_ledger(root, &request.operation_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed);
|
||||
assert_eq!(ledger.idempotency_key, request.idempotency_key);
|
||||
assert_eq!(
|
||||
ledger.remote_operation_id.as_deref(),
|
||||
Some("completed-background-removal")
|
||||
);
|
||||
let replay = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
derive_local_project_resource_at(request.clone()),
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replay.operation_id, result.operation_id);
|
||||
assert_eq!(
|
||||
replay.committed_project_revision,
|
||||
result.committed_project_revision
|
||||
);
|
||||
assert_eq!(replay.asset, result.asset);
|
||||
assert_eq!(
|
||||
replay
|
||||
.manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.id == format!("edit-{}", request.operation_id))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_resource_commit_wait_keeps_operation_and_project_guards() {
|
||||
let _session_guard = clear_test_platform_session();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path();
|
||||
let request = staged_background_removal_for_contention(root);
|
||||
let operation_lock = resource_edit_operation_lock(root, &request.operation_id).unwrap();
|
||||
let project_lock = resource_edit_project_mutation_lock(root).unwrap();
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
async {
|
||||
let mut commit = Box::pin(derive_local_project_resource_at(request.clone()));
|
||||
assert!(futures::poll!(commit.as_mut()).is_pending());
|
||||
drop(commit);
|
||||
// JoinHandle 被丢弃后阻塞任务仍会继续,互斥必须随任务保留。
|
||||
assert!(operation_lock.try_lock().is_err());
|
||||
assert!(project_lock.try_lock().is_err());
|
||||
let _finished =
|
||||
tokio::time::timeout(Duration::from_secs(10), operation_lock.lock())
|
||||
.await
|
||||
.expect("阻塞提交应在项目锁释放后完成");
|
||||
Ok(())
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
read_resource_edit_ledger(root, &request.operation_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.phase,
|
||||
ResourceEditLedgerPhase::Committed
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision,
|
||||
before + 1
|
||||
);
|
||||
assert!(project_lock.try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_commit_timeout_preserves_staging_and_resumes_original_operation() {
|
||||
let _session_guard = clear_test_platform_session();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path();
|
||||
let request = staged_background_removal_for_contention(root);
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision;
|
||||
let ledger_before = read_resource_edit_ledger(root, &request.operation_id).unwrap();
|
||||
let lock = acquire_project_write_lock(root, "conversation.write").unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let error = derive_local_project_resource_at(request.clone())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_secs(9),
|
||||
"未使用完整有界等待:{error}"
|
||||
);
|
||||
assert!(error.contains("commandId=conversation.write"), "{error}");
|
||||
assert!(error.contains("ownerIsSelf=true"), "{error}");
|
||||
assert_eq!(
|
||||
read_resource_edit_ledger(root, &request.operation_id).unwrap(),
|
||||
ledger_before
|
||||
);
|
||||
assert_eq!(
|
||||
read_resource_edit_staging(root, &request.operation_id).unwrap(),
|
||||
resource_editor_test_png()
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision,
|
||||
before
|
||||
);
|
||||
drop(lock);
|
||||
let recovered = derive_local_project_resource_at(request.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered.operation_id, request.operation_id);
|
||||
assert_eq!(recovered.committed_project_revision, before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_kind_provenance_uses_stable_kebab_case_values() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -38,9 +38,9 @@ fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials(
|
||||
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
|
||||
let mut config = read_game_creator_app_config().unwrap().config;
|
||||
assert_eq!(config.llm.model, "a/v1");
|
||||
let selected = select_game_creator_model("b:v2".into(), false, None).unwrap();
|
||||
let selected = select_game_creator_model("b:v2".into(), false, None, None).unwrap();
|
||||
assert_eq!(selected.config.llm.model, "b:v2");
|
||||
assert!(select_game_creator_model("not-listed".into(), false, None).is_err());
|
||||
assert!(select_game_creator_model("not-listed".into(), false, None, None).is_err());
|
||||
config.llm.visible_models = vec!["b:v2".into()];
|
||||
config.llm.api_key = "changed-fixture-key".into();
|
||||
write_game_creator_app_config(config).unwrap();
|
||||
@@ -817,6 +817,7 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
agent_llm,
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
selected_model_protocol: None,
|
||||
})
|
||||
.expect("write runtime config");
|
||||
|
||||
@@ -964,7 +965,7 @@ fn app_config_model_selection_only_updates_model_overlay() {
|
||||
)
|
||||
.expect("write overlay");
|
||||
let saved =
|
||||
select_game_creator_model("new-model".to_string(), true, None).expect("select model");
|
||||
select_game_creator_model("new-model".to_string(), true, None, None).expect("select model");
|
||||
let effective = load_game_creator_app_config().expect("effective config");
|
||||
assert_eq!(saved.config.selected_model_id, "new-model");
|
||||
assert_eq!(effective.selected_model_id, "new-model");
|
||||
@@ -986,13 +987,13 @@ fn model_selection_default_flag_round_trips() {
|
||||
fs::create_dir_all(&root).expect("runtime config dir");
|
||||
let _guard = use_test_runtime_config_dir(root.clone());
|
||||
|
||||
let saved =
|
||||
select_game_creator_model("quality".to_string(), true, None).expect("select default model");
|
||||
let saved = select_game_creator_model("quality".to_string(), true, None, None)
|
||||
.expect("select default model");
|
||||
assert_eq!(saved.config.selected_model_id, "quality");
|
||||
assert!(saved.config.selected_model_is_default);
|
||||
|
||||
let saved =
|
||||
select_game_creator_model("fast".to_string(), false, None).expect("select explicit model");
|
||||
let saved = select_game_creator_model("fast".to_string(), false, None, None)
|
||||
.expect("select explicit model");
|
||||
assert_eq!(saved.config.selected_model_id, "fast");
|
||||
assert!(!saved.config.selected_model_is_default);
|
||||
|
||||
@@ -1021,6 +1022,7 @@ fn app_config_write_rejects_invalid_api_kind() {
|
||||
agent_llm: BTreeMap::new(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
selected_model_protocol: None,
|
||||
});
|
||||
|
||||
assert!(result
|
||||
@@ -1048,6 +1050,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
|
||||
agent_llm: BTreeMap::new(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
selected_model_protocol: None,
|
||||
});
|
||||
|
||||
assert!(result
|
||||
@@ -1075,6 +1078,7 @@ fn app_config_write_rejects_too_small_request_timeout() {
|
||||
agent_llm: BTreeMap::new(),
|
||||
selected_model_id: "default".to_string(),
|
||||
selected_model_is_default: false,
|
||||
selected_model_protocol: None,
|
||||
});
|
||||
|
||||
assert!(result
|
||||
|
||||
@@ -16,6 +16,44 @@ const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from
|
||||
const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// 在另一线程持有真实项目锁;单线程 runtime 必须能在释放锁前推进心跳。
|
||||
pub(crate) async fn with_conversation_write_contention<T>(
|
||||
root: &Path,
|
||||
operation: impl std::future::Future<Output = Result<T, String>>,
|
||||
while_waiting: impl FnOnce(),
|
||||
) -> Result<T, String> {
|
||||
let root = root.to_path_buf();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let _lock = acquire_project_write_lock(&root, "conversation.write").unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
// 若回归为 runtime 同步阻塞,限时释放以让断言失败而不是挂死测试。
|
||||
let _ = release_rx.recv_timeout(Duration::from_secs(5));
|
||||
});
|
||||
ready_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let finished = AtomicBool::new(false);
|
||||
let started = Instant::now();
|
||||
let (result, ()) = tokio::join!(
|
||||
async {
|
||||
let result = operation.await;
|
||||
finished.store(true, Ordering::SeqCst);
|
||||
result
|
||||
},
|
||||
async {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let elapsed = started.elapsed();
|
||||
let completed_while_locked = finished.load(Ordering::SeqCst);
|
||||
while_waiting();
|
||||
let _ = release_tx.send(());
|
||||
assert!(elapsed < Duration::from_secs(3), "项目锁等待阻塞了 runtime");
|
||||
assert!(!completed_while_locked, "项目锁释放前操作已返回");
|
||||
}
|
||||
);
|
||||
holder.join().unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
// 委派测试显式持有与正式 action 执行链相同的项目写锁,随后调用现役核心。
|
||||
fn observe_agent_runtime_agent_delegate(
|
||||
root: &Path,
|
||||
|
||||
@@ -3860,6 +3860,23 @@ fn local_project_file_listing_skips_engine_generated_directories_only_for_engine
|
||||
.expect("write cocos package.json");
|
||||
|
||||
let listed = list_local_project_files_at(cocos).expect("list cocos project files");
|
||||
for scope in ["library", "library/imported/hero.json", "temp/programming"] {
|
||||
assert!(
|
||||
list_local_project_files_scoped_at(cocos, Some(scope), |_| false)
|
||||
.expect("list generated scope")
|
||||
.files
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
fs::create_dir_all(cocos.join("assets/library")).unwrap();
|
||||
fs::write(cocos.join("assets/library/kept.png"), b"image").unwrap();
|
||||
assert!(
|
||||
list_local_project_files_scoped_at(cocos, Some("assets/library"), |_| false)
|
||||
.unwrap()
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "assets/library/kept.png")
|
||||
);
|
||||
let paths = listed
|
||||
.files
|
||||
.iter()
|
||||
|
||||
@@ -122,7 +122,7 @@ pub(crate) async fn run_ui_design_doc_workflow(
|
||||
)
|
||||
.await?;
|
||||
// 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。
|
||||
let cut_images = register_cut_image_sprites(root, &dto)?;
|
||||
let cut_images = register_cut_image_sprites(root, &dto).await?;
|
||||
let mut backfill_errors = cut_images.errors;
|
||||
progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?;
|
||||
backfill_errors.extend(apply_separation(
|
||||
|
||||
+34
-3
@@ -6,7 +6,7 @@
|
||||
//! 说明,不回滚已经登记成功的资源。
|
||||
|
||||
use super::normalize_cut_image_path;
|
||||
use crate::import_local_project_assets_for_agent;
|
||||
use crate::import_local_project_assets_for_agent_in_blocking_pool;
|
||||
use crate::ui_editor::commands::SeparationDTO;
|
||||
use crate::ui_editor::resource::sprite::SpriteAsset;
|
||||
use crate::ui_editor::utils::SpriteAssetId;
|
||||
@@ -29,7 +29,7 @@ pub(crate) struct CutImageSprites {
|
||||
pub(crate) errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn register_cut_image_sprites(
|
||||
pub(crate) async fn register_cut_image_sprites(
|
||||
root: &Path,
|
||||
dto: &SeparationDTO,
|
||||
) -> Result<CutImageSprites, String> {
|
||||
@@ -43,7 +43,8 @@ pub(crate) fn register_cut_image_sprites(
|
||||
}
|
||||
let mut imported_by_path: Vec<(String, ImportedAsset)> = Vec::new();
|
||||
for batch in unique_paths.chunks(CUT_IMAGE_IMPORT_BATCH_SIZE) {
|
||||
let imported = import_local_project_assets_for_agent(root, batch)?;
|
||||
let imported =
|
||||
import_local_project_assets_for_agent_in_blocking_pool(root, batch, None).await?;
|
||||
if imported.assets.len() != batch.len() {
|
||||
return Err(format!(
|
||||
"本地资源登记结果数量不匹配:请求 {} 个,返回 {} 个",
|
||||
@@ -140,6 +141,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cut_image_registration_waits_without_blocking_worker() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let root = project.path();
|
||||
crate::init_local_game_project_at(root, "cut-import-wait", "切图导入等待").unwrap();
|
||||
image::DynamicImage::new_rgba8(2, 2)
|
||||
.save(root.join("game/cut.png"))
|
||||
.unwrap();
|
||||
let dto = SeparationDTO {
|
||||
bound_nodes: vec![bound("game/cut.png"), bound("game/cut.png")],
|
||||
problematic_nodes: Vec::new(),
|
||||
};
|
||||
let result = crate::tests::with_conversation_write_contention(
|
||||
root,
|
||||
register_cut_image_sprites(root, &dto),
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.sprites.len(), 1);
|
||||
assert!(result.by_path.contains_key("game/cut.png"));
|
||||
assert!(result.errors.is_empty());
|
||||
assert_eq!(
|
||||
crate::read_game_creator_agent_runtime_project_revision(root)
|
||||
.unwrap()
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_paths_normalize_separators_and_drop_duplicates() {
|
||||
let dto = SeparationDTO {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 首页自动创建失败的用户可见文案与证据出口。
|
||||
*
|
||||
* 两条硬约束:
|
||||
*
|
||||
* 1. 首页状态区只显示**本模块算出来的**文案。宿主返回的原始文本(Provider 英文、IPC 报错、
|
||||
* 测试夹具字符串)一律不上屏,只作为证据写进 `application.log`;原来的写法把整段底层文本
|
||||
* 透传上屏,或者反过来把它覆盖成通用文案,两种都不对。
|
||||
* 2. 兜底必须是专门的兜底文案,不能借用某个具体原因冒充(把 IPC 断连说成「环境预检未通过」、
|
||||
* 把已经建好的工作区说成「创建未完成」都属此类)。宿主给了机器码就显示机器码,给不出就
|
||||
* 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。
|
||||
*/
|
||||
|
||||
/** 未分类失败的专用兜底文案。 */
|
||||
export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试';
|
||||
|
||||
export const HOME_CREATION_AFTER_PROJECT_FAILURE =
|
||||
'工作区已创建,但未能进入项目开发;请从「已创建的工作区」打开后重试。';
|
||||
|
||||
export const HOME_CREATION_IPC_FAILURE =
|
||||
'与客户端本体的连接中断,创建未完成;请重启客户端后重试。';
|
||||
|
||||
export const HOME_CREATION_AFTER_PROJECT_IPC_FAILURE =
|
||||
'工作区已创建;与客户端本体的连接中断,未能进入项目开发;请重启客户端后从「已创建的工作区」打开。';
|
||||
|
||||
/** WebView / Tauri 调用通道自身失联时的固定形状。 */
|
||||
const HOME_CREATION_TRANSPORT_PATTERNS = [
|
||||
'failed to fetch',
|
||||
'ipc custom protocol failed',
|
||||
'window.__tauri__',
|
||||
] as const;
|
||||
|
||||
/** 宿主错误里的机器码(`project-revision-conflict`、`web-build-timeout` 这类连字符标识)。 */
|
||||
const HOME_CREATION_HOST_CODE = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/;
|
||||
|
||||
/**
|
||||
* 已经整理好用户文案的创建失败。
|
||||
*
|
||||
* `diagnostic` 保留宿主原始文本,只用于日志与诊断;因为它可能包含 Provider、路径或凭据文本,
|
||||
* 不允许直接进入首页状态区。
|
||||
*/
|
||||
export class HomeCreationFailure extends Error {
|
||||
readonly userMessage: string;
|
||||
readonly diagnostic: string;
|
||||
|
||||
constructor(userMessage: string, diagnostic: string) {
|
||||
super(userMessage);
|
||||
this.name = 'HomeCreationFailure';
|
||||
this.userMessage = userMessage;
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export function homeCreationDiagnosticText(error: unknown): string {
|
||||
if (error instanceof Error) return error.message.trim();
|
||||
if (typeof error === 'string') return error.trim();
|
||||
return '';
|
||||
}
|
||||
|
||||
function isHomeCreationTransportText(text: string): boolean {
|
||||
const normalized = text.toLowerCase();
|
||||
return HOME_CREATION_TRANSPORT_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由宿主错误算出用户可见文案。
|
||||
*
|
||||
* `projectCreated` 必须由调用方按自己掌握的事实传入:工作区已经建好时,文案要保留这个事实
|
||||
* 并指向「已创建的工作区」恢复入口。
|
||||
*/
|
||||
export function homeCreationFailureMessage(
|
||||
error: unknown,
|
||||
options: { projectCreated?: boolean } = {},
|
||||
): string {
|
||||
const projectCreated = options.projectCreated === true;
|
||||
const text = homeCreationDiagnosticText(error);
|
||||
if (isHomeCreationTransportText(text)) {
|
||||
return projectCreated
|
||||
? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE
|
||||
: HOME_CREATION_IPC_FAILURE;
|
||||
}
|
||||
const code = HOME_CREATION_HOST_CODE.exec(text)?.[0];
|
||||
if (code) {
|
||||
return projectCreated
|
||||
? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。`
|
||||
: `创建未完成(${code});请重试。`;
|
||||
}
|
||||
return projectCreated
|
||||
? HOME_CREATION_AFTER_PROJECT_FAILURE
|
||||
: HOME_CREATION_UNCLASSIFIED_FAILURE;
|
||||
}
|
||||
|
||||
/** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */
|
||||
export function createHomeCreationFailure(
|
||||
error: unknown,
|
||||
options: { projectCreated?: boolean } = {},
|
||||
): HomeCreationFailure {
|
||||
return new HomeCreationFailure(
|
||||
homeCreationFailureMessage(error, options),
|
||||
homeCreationDiagnosticText(error),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页出口:只认控制器包装过的文案。
|
||||
*
|
||||
* 走到这里却不是 `HomeCreationFailure`,说明失败发生在控制器之外,只能落回专用兜底——
|
||||
* 绝不把未知文本原样渲染到状态区。
|
||||
*/
|
||||
export function resolveHomeCreationFailureMessage(error: unknown): string {
|
||||
return error instanceof HomeCreationFailure
|
||||
? error.userMessage
|
||||
: HOME_CREATION_UNCLASSIFIED_FAILURE;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user