Merge pull request 'AGC 资源画布与替换改造 V3.0' (#316) from feat/agc-canvas-resource-workbench-v3 into master
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/316
This commit was merged in pull request #316.
This commit is contained in:
@@ -371,7 +371,15 @@ async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
|
||||
}
|
||||
}
|
||||
|
||||
async function secureWindowsPrivateRuntimePath(targetPath, options) {
|
||||
async function secureWindowsPrivateRuntimePath(
|
||||
targetPath,
|
||||
options,
|
||||
secureWindowsPath = null,
|
||||
) {
|
||||
if (secureWindowsPath) {
|
||||
await secureWindowsPath(targetPath, options);
|
||||
return;
|
||||
}
|
||||
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
|
||||
'./game-creator-config-wizard.mjs'
|
||||
);
|
||||
@@ -383,6 +391,7 @@ async function copyPrivateRuntimeConfigEntry(
|
||||
runtimeConfigDir,
|
||||
fileName,
|
||||
required,
|
||||
secureWindowsPath = null,
|
||||
) {
|
||||
const sourcePath = path.join(sourceConfigDir, fileName);
|
||||
const sourceMetadata = await lstat(sourcePath).catch((error) => {
|
||||
@@ -402,9 +411,11 @@ async function copyPrivateRuntimeConfigEntry(
|
||||
const sourceBytes = await readFile(sourcePath);
|
||||
const destinationFile = await open(destinationPath, 'wx', 0o600);
|
||||
try {
|
||||
await secureWindowsPrivateRuntimePath(destinationPath, {
|
||||
isDirectory: false,
|
||||
});
|
||||
await secureWindowsPrivateRuntimePath(
|
||||
destinationPath,
|
||||
{ isDirectory: false },
|
||||
secureWindowsPath,
|
||||
);
|
||||
await destinationFile.writeFile(sourceBytes);
|
||||
await destinationFile.sync();
|
||||
} finally {
|
||||
@@ -437,7 +448,11 @@ async function copyPrivateRuntimeConfigEntry(
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
|
||||
export async function prepareSwarmTestRuntimeConfig(
|
||||
sourceConfigDir,
|
||||
tempRoot,
|
||||
{ secureWindowsPath = null } = {},
|
||||
) {
|
||||
if (!path.isAbsolute(sourceConfigDir)) {
|
||||
throw new Error('配置来源目录必须是绝对路径');
|
||||
}
|
||||
@@ -457,9 +472,11 @@ export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
|
||||
);
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
|
||||
isDirectory: true,
|
||||
});
|
||||
await secureWindowsPrivateRuntimePath(
|
||||
runtimeConfigDir,
|
||||
{ isDirectory: true },
|
||||
secureWindowsPath,
|
||||
);
|
||||
} else {
|
||||
await chmod(runtimeConfigDir, 0o700);
|
||||
}
|
||||
@@ -477,12 +494,14 @@ export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
|
||||
runtimeConfigDir,
|
||||
configFileName,
|
||||
true,
|
||||
secureWindowsPath,
|
||||
);
|
||||
await copyPrivateRuntimeConfigEntry(
|
||||
canonicalSourceConfigDir,
|
||||
runtimeConfigDir,
|
||||
localConfigFileName,
|
||||
false,
|
||||
secureWindowsPath,
|
||||
);
|
||||
return {
|
||||
path: await realpath(runtimeConfigDir),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
@@ -21,6 +21,7 @@
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
|
||||
@@ -16,6 +16,7 @@ mod design_runtime;
|
||||
mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_references;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
@@ -43,6 +44,7 @@ pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_codex_references::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
|
||||
@@ -16,7 +16,21 @@ const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
|
||||
"CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED";
|
||||
const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
/// 单条 JSON-RPC 消息(NDJSON 一行)的字节上限:**同时**是 stdout 读侧上限与 stdin 写侧守卫。
|
||||
///
|
||||
/// 两侧必须共用这一个常量。2026-09-11 的隔离探针实测(codex-cli 0.147.0,AGC 捆绑版本):
|
||||
/// `experimentalRawEvents: true` 的线程收到 `thread/inject_items` 后,会**逐条原样回显**
|
||||
/// `rawResponseItem/completed` —— 注入一个 5 MiB 的 item,stdout 就回一条 5 243 245 字节的单行。
|
||||
/// 因此「我们注入得进去」就必须「我们读得回来」;两侧取不同值(曾为读侧 4 MiB、图片单张 5 MiB/
|
||||
/// 单次 16 MiB)会让一次**原本合法**的注入在随后的读行里被误判成连接级故障。
|
||||
///
|
||||
/// 32 MiB 的依据:单张图 base64 data URL 上限 10 MiB(`..._IMAGE_MAX_BYTES * 2`)、单次请求图片
|
||||
/// 总量上限 16 MiB(`..._IMAGE_TOTAL_MAX_BYTES`,base64 后约 21.3 MiB)+ JSON 信封,取 32 MiB
|
||||
/// 覆盖现有写侧全部单一消息;再大就失去内存/DoS 边界的意义(每连接单行最多驻留该数量级)。
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// 回显信封余量:item 会被包进 `{"method":"rawResponseItem/completed","params":{…,"item":…}}`
|
||||
/// 之后再输出,所以**单条 item** 允许占用的字节数必须小于整行上限。
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES: usize = 8 * 1024;
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024;
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024;
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128;
|
||||
@@ -44,6 +58,13 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX
|
||||
"codex-app-server-terminal-unknown:";
|
||||
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
|
||||
"codex-app-server-error:";
|
||||
/// DirectProject 历史注入超限:专属前缀,供 `direct_runtime.rs` 判「可重试性」与给专属恢复提示。
|
||||
///
|
||||
/// 命中它意味着**同一份历史每次都会得到同一结论**(历史文件不变则载荷不变),所以按不可重试
|
||||
/// 处理,与 `DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS` 同口径(见技术方案
|
||||
/// `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 末节)。
|
||||
pub(in crate::agent) const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX: &str =
|
||||
"DirectProject 历史注入载荷超过单行上限";
|
||||
const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str =
|
||||
"You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
|
||||
const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
|
||||
@@ -2335,6 +2356,11 @@ impl CodexAppServerConnection {
|
||||
let mut bytes = serde_json::to_vec(value)
|
||||
.map_err(|error| format!("序列化 Codex app-server JSON-RPC 失败:{error}"))?;
|
||||
bytes.push(b'\n');
|
||||
// 写侧守卫:绝不写出自己读不回来的行(两侧共用 `..._LINE_MAX_BYTES`)。
|
||||
// 失败关闭并给出字节数,**不做任何截断**。
|
||||
if let Some(error) = game_creator_codex_app_server_message_oversize_error(bytes.len()) {
|
||||
return Err(error);
|
||||
}
|
||||
let mut writer = self.inner.writer.lock().await;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
@@ -2657,13 +2683,18 @@ impl CodexAppServerConnection {
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
};
|
||||
if let Err(error) = self
|
||||
.request(
|
||||
"thread/inject_items",
|
||||
serde_json::json!({"threadId": thread_id, "items": items}),
|
||||
)
|
||||
.await
|
||||
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
|
||||
let payload_bytes = serde_json::to_vec(¶ms)
|
||||
.map(|bytes| bytes.len().saturating_add(1))
|
||||
.unwrap_or(usize::MAX);
|
||||
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
|
||||
if let Some(error) =
|
||||
direct_project_history_injection_oversize_error(¶ms, payload_bytes)
|
||||
{
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
if let Err(error) = self.request("thread/inject_items", params).await {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::Transport(error));
|
||||
}
|
||||
@@ -3471,6 +3502,61 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
}
|
||||
}
|
||||
|
||||
/// 写侧守卫的纯函数部分(便于单测):超限时给出带字节数的错误。
|
||||
///
|
||||
/// 与读侧共用同一个上限,保证「我们能注入的」一定「我们读得回来」。
|
||||
fn game_creator_codex_app_server_message_oversize_error(byte_len: usize) -> Option<String> {
|
||||
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
|
||||
(byte_len > limit).then(|| {
|
||||
format!(
|
||||
"Codex app-server JSON-RPC 单行超过大小上限(写侧):{byte_len} 字节 > 上限 {limit} 字节;未发送、未截断"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// DirectProject 历史注入的前置校验(纯函数,便于单测)。
|
||||
///
|
||||
/// 判据来自实测:app-server 会把注入的 item **逐条原样回显**,所以
|
||||
/// ① 单条 item 必须 ≤ 上限 − 回显信封余量;② 整份载荷本身也必须 ≤ 上限。
|
||||
/// 任一不满足即**失败关闭**(指名 `itemId` 与字节数),**不截断、不摘要、不改写**历史。
|
||||
fn direct_project_history_injection_oversize_error(
|
||||
params: &serde_json::Value,
|
||||
payload_bytes: usize,
|
||||
) -> Option<String> {
|
||||
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
|
||||
if payload_bytes > limit {
|
||||
return Some(format!(
|
||||
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:整份历史载荷 {payload_bytes} 字节 > 上限 {limit} 字节;未注入、未截断"
|
||||
));
|
||||
}
|
||||
let Some(items) = params.get("items").and_then(serde_json::Value::as_array) else {
|
||||
return None;
|
||||
};
|
||||
let item_limit = limit.saturating_sub(GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES);
|
||||
for item in items {
|
||||
let Ok(bytes) = serde_json::to_vec(item) else {
|
||||
return Some(format!(
|
||||
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:历史记录无法序列化;未注入、未截断"
|
||||
));
|
||||
};
|
||||
if bytes.len() > item_limit {
|
||||
let item_id = item
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("<无 id>");
|
||||
let item_type = item
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("<无 type>");
|
||||
return Some(format!(
|
||||
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:单条记录 {} 字节 > 单条上限 {item_limit} 字节(itemId={item_id}, type={item_type});未注入、未截断",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn read_bounded_game_creator_codex_app_server_line<R>(
|
||||
reader: &mut R,
|
||||
) -> Result<Option<Vec<u8>>, String>
|
||||
@@ -5546,6 +5632,66 @@ while IFS= read -r line; do :; done
|
||||
assert!(!diagnostic.contains(std::str::from_utf8(&secret).expect("fixture utf8")));
|
||||
}
|
||||
|
||||
/// 写侧对称守卫:我们绝不写出自己读不回来的行。
|
||||
///
|
||||
/// 与上面那条读侧用例共用 `..._LINE_MAX_BYTES`,所以这条断言同时锁住「两侧同界」这个不变量:
|
||||
/// 上限本身必须放行,上限 + 1 必须失败关闭(而不是被静默截断)。
|
||||
#[test]
|
||||
fn codex_app_server_write_guard_matches_the_read_cap() {
|
||||
assert!(game_creator_codex_app_server_message_oversize_error(
|
||||
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES
|
||||
)
|
||||
.is_none());
|
||||
|
||||
let oversized = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1;
|
||||
let error = game_creator_codex_app_server_message_oversize_error(oversized)
|
||||
.expect("oversized outbound message must be rejected before it is written");
|
||||
assert!(error.contains("单行超过大小上限(写侧)"));
|
||||
assert!(error.contains(&oversized.to_string()));
|
||||
assert!(error.contains("未发送、未截断"));
|
||||
}
|
||||
|
||||
/// DirectProject 历史注入的前置校验:**单条 item** 与**整份载荷**都必须失败关闭。
|
||||
///
|
||||
/// 形状照抄实测(隔离探针,codex-cli 0.147.0):`thread/inject_items` 注入的 item 会被
|
||||
/// app-server 逐条原样回显成一条 `rawResponseItem/completed`,所以「单条 item 超限」就等于
|
||||
/// 「回显行超限」——必须在注入前拦下,而不是让它在读行时变成连接级故障。
|
||||
#[test]
|
||||
fn direct_project_history_injection_rejects_oversized_items_and_payloads() {
|
||||
let ok_params = serde_json::json!({
|
||||
"threadId": "probe-thread",
|
||||
"items": [{ "type": "message", "id": "msg_1", "role": "user" }],
|
||||
});
|
||||
assert!(direct_project_history_injection_oversize_error(&ok_params, 1024).is_none());
|
||||
|
||||
let payload_error = direct_project_history_injection_oversize_error(
|
||||
&ok_params,
|
||||
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1,
|
||||
)
|
||||
.expect("oversized payload must be rejected");
|
||||
assert!(payload_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
|
||||
assert!(payload_error.contains("整份历史载荷"));
|
||||
assert!(payload_error.contains("未注入、未截断"));
|
||||
|
||||
let huge = "x".repeat(GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES);
|
||||
let big_params = serde_json::json!({
|
||||
"threadId": "probe-thread",
|
||||
"items": [{
|
||||
"type": "message",
|
||||
"id": "msg_big",
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": huge }],
|
||||
}],
|
||||
});
|
||||
let item_error = direct_project_history_injection_oversize_error(&big_params, 1024)
|
||||
.expect("oversized single item must be rejected");
|
||||
assert!(item_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
|
||||
assert!(item_error.contains("单条记录"));
|
||||
assert!(item_error.contains("itemId=msg_big"));
|
||||
assert!(item_error.contains("type=message"));
|
||||
assert!(item_error.contains("未注入、未截断"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn codex_app_server_uses_initialize_thread_and_turn_protocol() {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
|
||||
|
||||
const DIRECT_CODEX_REFERENCE_HEADER: &str =
|
||||
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub(crate) enum DirectCodexTurnReference {
|
||||
#[serde(rename = "resource")]
|
||||
Resource(DirectCodexResourceReference),
|
||||
#[serde(rename = "runtime-region")]
|
||||
RuntimeRegion(DirectCodexRuntimeRegionReference),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexResourceReference {
|
||||
pub(crate) resource_id: String,
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexRuntimeRegionReference {
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) run_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) width: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
|
||||
let sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(max_chars)
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
|
||||
}
|
||||
|
||||
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
|
||||
value
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
|
||||
}
|
||||
|
||||
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
|
||||
let basename = asset
|
||||
.local_path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(&asset.id);
|
||||
let without_extension = basename
|
||||
.rsplit_once('.')
|
||||
.map(|(name, _)| name)
|
||||
.unwrap_or(basename)
|
||||
.trim();
|
||||
if without_extension.is_empty() {
|
||||
asset.id.clone()
|
||||
} else {
|
||||
without_extension
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
||||
let resource_id = value.trim();
|
||||
if resource_id.is_empty()
|
||||
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|
||||
|| resource_id.chars().any(char::is_control)
|
||||
{
|
||||
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
|
||||
}
|
||||
Ok(resource_id.to_string())
|
||||
}
|
||||
|
||||
fn render_resource_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexResourceReference,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let local_path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| asset_display_label(asset));
|
||||
let source = sanitize_reference_source(reference.source.as_deref())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
Ok(format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
))
|
||||
}
|
||||
|
||||
fn render_runtime_region_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexRuntimeRegionReference,
|
||||
) -> Result<String, String> {
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| "运行画面区域".to_string());
|
||||
let run_id = sanitize_reference_source(reference.run_id.as_deref());
|
||||
let version_id = sanitize_reference_source(reference.version_id.as_deref());
|
||||
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
|
||||
let element_role = sanitize_reference_element(reference.element_role.as_deref());
|
||||
let text = reference
|
||||
.text
|
||||
.as_deref()
|
||||
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
|
||||
let width = sanitize_reference_dimension(reference.width);
|
||||
let height = sanitize_reference_dimension(reference.height);
|
||||
|
||||
// `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时,
|
||||
// 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界
|
||||
// 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的
|
||||
// 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。
|
||||
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!(
|
||||
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选"
|
||||
));
|
||||
}
|
||||
let mut related_resource_ids = Vec::new();
|
||||
for resource_id in &reference.resource_ids {
|
||||
let resource_id = validate_resource_reference_id(resource_id)?;
|
||||
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
|
||||
return Err("运行画面引用的素材已变化,请重新点选".to_string());
|
||||
}
|
||||
// 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。
|
||||
// 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。
|
||||
if !related_resource_ids.contains(&resource_id) {
|
||||
related_resource_ids.push(resource_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = vec![format!("名称:{label}")];
|
||||
if let Some(run_id) = run_id {
|
||||
parts.push(format!("运行标识:{run_id}"));
|
||||
}
|
||||
if let Some(version_id) = version_id {
|
||||
parts.push(format!("版本标识:{version_id}"));
|
||||
}
|
||||
if let Some(element_tag) = element_tag {
|
||||
parts.push(format!("元素:{element_tag}"));
|
||||
}
|
||||
if let Some(element_role) = element_role {
|
||||
parts.push(format!("角色:{element_role}"));
|
||||
}
|
||||
if let Some(text) = text {
|
||||
parts.push(format!("文本摘要:{text}"));
|
||||
}
|
||||
if let (Some(width), Some(height)) = (width, height) {
|
||||
parts.push(format!("尺寸:{width}x{height}"));
|
||||
}
|
||||
if !related_resource_ids.is_empty() {
|
||||
parts.push(format!("关联素材 ID:{}", related_resource_ids.join(",")));
|
||||
}
|
||||
Ok(format!("- 运行画面区域:{}", parts.join(";")))
|
||||
}
|
||||
|
||||
pub(crate) fn render_direct_codex_references_section(
|
||||
root: &Path,
|
||||
references: &[DirectCodexTurnReference],
|
||||
) -> Result<Option<String>, String> {
|
||||
if references.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut lines = Vec::with_capacity(references.len());
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Some(
|
||||
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
|
||||
.chain(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_project() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().expect("temp project");
|
||||
let root = directory.path();
|
||||
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
|
||||
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-hero".to_string(),
|
||||
kind: "character".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("character"),
|
||||
tags: Vec::new(),
|
||||
});
|
||||
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
|
||||
directory
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("素材 ID:asset-hero"));
|
||||
assert!(section.contains("名称:主角"));
|
||||
assert!(section.contains("项目路径:assets/hero.png"));
|
||||
assert!(!section.contains("C:\\secret.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_resource_fails_closed() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect_err("missing resource");
|
||||
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("运行画面区域:名称:开始按钮"));
|
||||
assert!(section.contains("文本摘要:开始游戏"));
|
||||
assert!(section.contains("尺寸:120x40"));
|
||||
assert!(section.contains("关联素材 ID:asset-hero"));
|
||||
assert!(!section.contains("onclick"));
|
||||
assert!(!section.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_dedupes_and_bounds_related_resource_ids() {
|
||||
let project = fixture_project();
|
||||
// 同一个 id 重复出现只应产生一条关联。
|
||||
let duplicated: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&duplicated),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(
|
||||
section.contains("关联素材 ID:asset-hero\n")
|
||||
|| section.trim_end().ends_with("关联素材 ID:asset-hero"),
|
||||
"{section}"
|
||||
);
|
||||
assert!(
|
||||
!section.contains("asset-hero,"),
|
||||
"重复 id 不得在注入提示词里重复出现:{section}"
|
||||
);
|
||||
|
||||
// 超出上限直接失败关闭:不能按对方给的长度注入提示词。
|
||||
let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1)
|
||||
.map(|_| "\"asset-hero\"".to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let oversized: DirectCodexTurnReference = serde_json::from_str(&format!(
|
||||
r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"#
|
||||
))
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&oversized),
|
||||
)
|
||||
.expect_err("oversized resource id list must fail closed");
|
||||
assert!(error.contains("最多关联"), "{error}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1788,6 +1788,15 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
|
||||
{
|
||||
return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集";
|
||||
}
|
||||
if direct_project_history_injection_oversize(error) {
|
||||
return "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求";
|
||||
}
|
||||
if direct_project_history_shape_failure(error) {
|
||||
return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求";
|
||||
}
|
||||
if direct_project_history_contention_failure(error) {
|
||||
return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
|
||||
}
|
||||
match stage {
|
||||
DirectCodexFailureStage::ArtPreparation => {
|
||||
"平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断"
|
||||
@@ -1808,6 +1817,9 @@ fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> {
|
||||
if direct_codex_error_is_mud_points_insufficient(error) {
|
||||
return Some("泥点余额不足");
|
||||
}
|
||||
if direct_project_history_injection_oversize(error) {
|
||||
return Some("项目对话历史有单条记录超过注入上限");
|
||||
}
|
||||
if private_external_editor_credentials_storage_preparation_failed(error) {
|
||||
return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据");
|
||||
}
|
||||
@@ -1821,6 +1833,14 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
|
||||
if direct_codex_error_is_mud_points_insufficient(error) {
|
||||
return false;
|
||||
}
|
||||
if direct_project_history_shape_failure(error) {
|
||||
return false;
|
||||
}
|
||||
// 注入超限与「行形状」同类:同一份历史文件每次读都会得到同一结论,重试只会
|
||||
// 再次注入同一份(且本轮用户消息已先追加进同一文件,载荷只会更大),因此不标可重试。
|
||||
if direct_project_history_injection_oversize(error) {
|
||||
return false;
|
||||
}
|
||||
![
|
||||
"private-external-editor-credential-storage-preparation-failed",
|
||||
"private-external-editor-credential-persistence-failed",
|
||||
@@ -1834,6 +1854,48 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
|
||||
.any(|marker| error.contains(marker))
|
||||
}
|
||||
|
||||
/// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果,
|
||||
/// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。
|
||||
const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[
|
||||
"DirectProject 历史记录类型无效",
|
||||
"DirectProject 历史记录缺少 payload",
|
||||
"解析 DirectProject 历史失败",
|
||||
];
|
||||
|
||||
/// DirectProject 历史**注入超限**的失败标记。
|
||||
///
|
||||
/// 判据是「同一份历史 ⇒ 同一份载荷 ⇒ 同一结论」:本次注入因为单条记录(或整份载荷)超过
|
||||
/// 单行上限而被前置校验拦下(前缀与字节数定义在 `agent/codex_app_server.rs`),重试只会再注入
|
||||
/// 同一份、且更大的历史。所以按不可重试处理,并给专属恢复提示;这里沿用本文件既有的
|
||||
/// 「字面量子串」口径,只取前缀的特征子串。
|
||||
const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS: &[&str] = &["历史注入载荷超过单行上限"];
|
||||
|
||||
fn direct_project_history_injection_oversize(error: &str) -> bool {
|
||||
DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| error.contains(marker))
|
||||
}
|
||||
|
||||
fn direct_project_history_shape_failure(error: &str) -> bool {
|
||||
DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| error.contains(marker))
|
||||
}
|
||||
|
||||
/// 追加写的**跨进程追加锁**超时。
|
||||
///
|
||||
/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身
|
||||
/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此
|
||||
/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。
|
||||
///
|
||||
/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`)
|
||||
/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在
|
||||
/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句
|
||||
/// 与真实原因不符的描述有机会出现。
|
||||
fn direct_project_history_contention_failure(error: &str) -> bool {
|
||||
error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER)
|
||||
}
|
||||
|
||||
fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
error.contains("泥点余额不足")
|
||||
@@ -4418,6 +4480,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
references: Option<Vec<DirectCodexTurnReference>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
@@ -4432,16 +4495,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let user_prompt = match render_direct_codex_user_prompt(
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
let attachments = attachments.unwrap_or_default();
|
||||
let references = references.unwrap_or_default();
|
||||
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
|
||||
{
|
||||
Ok(section) => section,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
} {
|
||||
if !user_prompt.trim().is_empty() {
|
||||
user_prompt.push_str("\n\n");
|
||||
}
|
||||
user_prompt.push_str(&reference_section);
|
||||
}
|
||||
if user_prompt.trim().is_empty() {
|
||||
audit.finish(false);
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
@@ -4535,6 +4615,59 @@ mod tests {
|
||||
assert!(!direct_codex_failure_is_retryable(error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() {
|
||||
let error =
|
||||
"DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl";
|
||||
assert!(direct_project_history_shape_failure(error));
|
||||
assert_eq!(
|
||||
direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error),
|
||||
"项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"
|
||||
);
|
||||
assert!(!direct_codex_failure_is_retryable(error));
|
||||
|
||||
// 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。
|
||||
let io_error = "打开 DirectProject 历史失败:拒绝访问";
|
||||
assert!(!direct_project_history_shape_failure(io_error));
|
||||
assert!(direct_codex_failure_is_retryable(io_error));
|
||||
}
|
||||
|
||||
/// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示,
|
||||
/// 追加锁超时才落到"历史未能落盘"。两条判据不得重叠。
|
||||
#[test]
|
||||
fn direct_project_history_contention_hint_is_append_lock_only() {
|
||||
let append_lock_timeout = format!(
|
||||
"获取DirectProject 历史追加写{}",
|
||||
crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER
|
||||
);
|
||||
assert!(direct_project_history_contention_failure(
|
||||
&append_lock_timeout
|
||||
));
|
||||
assert_eq!(
|
||||
direct_codex_failure_recovery_hint(
|
||||
DirectCodexFailureStage::CodeGeneration,
|
||||
&append_lock_timeout
|
||||
),
|
||||
"另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"
|
||||
);
|
||||
|
||||
let write_lock_contention = format!(
|
||||
"{}C:/project",
|
||||
crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX
|
||||
);
|
||||
assert!(
|
||||
!direct_project_history_contention_failure(&write_lock_contention),
|
||||
"项目写锁争用不得再落进历史争用判据"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_codex_failure_recovery_hint(
|
||||
DirectCodexFailureStage::CodeGeneration,
|
||||
&write_lock_contention
|
||||
),
|
||||
"当前项目仍有写入正在结束,请稍后再次发送该需求"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_turn_id_is_strictly_normalized_and_bounded() {
|
||||
assert_eq!(
|
||||
@@ -6517,6 +6650,50 @@ mod tests {
|
||||
assert!(!diagnostic.contains("provider.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
|
||||
.expect("init project");
|
||||
let history_path = root
|
||||
.path()
|
||||
.join(".agent/conversations/project.jsonl")
|
||||
.display()
|
||||
.to_string();
|
||||
let error = record_direct_codex_turn_failure(
|
||||
root.path(),
|
||||
DirectCodexTurnFailure::new(
|
||||
DirectCodexFailureStage::CodeGeneration,
|
||||
format!("DirectProject 历史记录类型无效:{history_path}"),
|
||||
),
|
||||
);
|
||||
|
||||
assert!(
|
||||
error.starts_with(
|
||||
"direct-codex-failure:v1 stage=code-generation retryable=false summary="
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains(
|
||||
";建议:项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求;"
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.ends_with("已保存脱敏项目诊断"), "{error}");
|
||||
|
||||
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
|
||||
let entries = std::fs::read_dir(&diagnostics)
|
||||
.expect("diagnostic directory")
|
||||
.filter_map(Result::ok)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let diagnostic = std::fs::read_to_string(entries[0].path().join("failure.json"))
|
||||
.expect("diagnostic sidecar");
|
||||
assert!(diagnostic.contains("\"retryable\": false"), "{diagnostic}");
|
||||
assert!(diagnostic.contains("旧格式已兼容读取"), "{diagnostic}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -1165,6 +1165,12 @@ fn bridge_registered_resource(
|
||||
"localPath": asset.local_path,
|
||||
"kind": asset.kind,
|
||||
"mediaType": asset.media_type,
|
||||
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
|
||||
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
|
||||
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
|
||||
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
|
||||
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
|
||||
"tags": asset.tags,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
"assetObjectId": asset.source.asset_object_id,
|
||||
@@ -2077,16 +2083,14 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result<String, String> {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("image");
|
||||
if !matches!(
|
||||
kind,
|
||||
"image" | "character" | "icon-spec" | "ui-prototype" | "publication-material"
|
||||
) {
|
||||
return Err(
|
||||
"工具参数 kind 只允许 image、character、icon-spec、ui-prototype 或 publication-material"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(kind.to_string())
|
||||
normalize_platform_art_asset_generation_kind(kind)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"工具参数 kind 只允许 {}",
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS.join("、")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
@@ -3189,6 +3193,8 @@ mod tests {
|
||||
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("canvas-1".to_string()),
|
||||
@@ -3252,6 +3258,8 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
image_sequence_duration_ms: Some(4_000),
|
||||
category: game_creation_app_asset_category_for_kind("character-animation"),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("canvas-1".to_string()),
|
||||
@@ -3271,6 +3279,8 @@ mod tests {
|
||||
projection["imageSequenceFrames"][0]["objectKey"],
|
||||
"animations/frame-01.png"
|
||||
);
|
||||
assert_eq!(projection["category"], "character");
|
||||
assert_eq!(projection["tags"], json!([]));
|
||||
let serialized = projection.to_string();
|
||||
assert!(!serialized.contains("signed.invalid"));
|
||||
assert!(!serialized.contains("private prompt"));
|
||||
@@ -3278,6 +3288,83 @@ mod tests {
|
||||
assert!(!serialized.contains("private.invalid"));
|
||||
}
|
||||
|
||||
/// 落盘分类是明确值时,Agent 侧原样透传。
|
||||
///
|
||||
/// (用例名从 `..._verbatim` 改成 `..._keeps_explicit...`:透传只在落盘值不是
|
||||
/// `unclassified` 时成立,落盘 `unclassified` 要按读显示口径自愈,见下一个用例。)
|
||||
#[test]
|
||||
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "spec-1".to_string(),
|
||||
kind: "spec".to_string(),
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/specs/hero.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category:
|
||||
shared_contracts::game_creation_app::GameCreationAppAssetCategory::UiInteraction,
|
||||
tags: vec!["主角".to_string(), "第一版".to_string()],
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: Some("private prompt".to_string()),
|
||||
model: Some("private model".to_string()),
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "spec");
|
||||
assert_eq!(projection["category"], "ui-interaction");
|
||||
assert_eq!(projection["tags"], json!(["主角", "第一版"]));
|
||||
assert_eq!(projection["imageSequenceFrames"], Value::Null);
|
||||
let serialized = projection.to_string();
|
||||
assert!(!serialized.contains("private prompt"));
|
||||
assert!(!serialized.contains("private model"));
|
||||
}
|
||||
|
||||
/// Agent 侧必须与 UI 看到同一个分类口径。
|
||||
///
|
||||
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
|
||||
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
|
||||
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "ui-1".to_string(),
|
||||
kind: "ui".to_string(),
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/UI 设计 1.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category:
|
||||
shared_contracts::game_creation_app::GameCreationAppAssetCategory::Unclassified,
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "ui");
|
||||
assert_eq!(projection["category"], "ui-interaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_success_warnings_are_redacted_before_serialization() {
|
||||
let root = tempfile::tempdir().expect("warning redaction root");
|
||||
|
||||
@@ -208,7 +208,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_generate_image",
|
||||
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图或游戏 UI 设计图都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。",
|
||||
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -216,13 +216,13 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS,
|
||||
"description": "完整图片描述;普通图片、角色、规范图或 UI 设计图均可"
|
||||
"description": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["image", "character", "icon-spec", "ui-prototype", "publication-material"],
|
||||
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
"default": "image",
|
||||
"description": "image=普通新图,character=角色图,icon-spec=视觉规范图,ui-prototype=完整 UI 设计图,publication-material=发布宣传图"
|
||||
"description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图),publication-material=发布宣传图"
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": "string",
|
||||
@@ -281,7 +281,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_list_registered_assets",
|
||||
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
|
||||
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、功能分类与自定义标签、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1024,15 +1024,10 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
|
||||
return mcp_tool_result(error, Vec::new(), true);
|
||||
}
|
||||
if let Some(kind) = arguments.get("kind") {
|
||||
if !kind.is_string()
|
||||
|| ![
|
||||
"image",
|
||||
"character",
|
||||
"icon-spec",
|
||||
"ui-prototype",
|
||||
"publication-material",
|
||||
]
|
||||
.contains(&kind.as_str().unwrap_or_default())
|
||||
if !kind
|
||||
.as_str()
|
||||
.and_then(normalize_platform_art_asset_generation_kind)
|
||||
.is_some()
|
||||
{
|
||||
return mcp_tool_result(
|
||||
"工具参数 kind 不是受支持的图片生成类型".to_string(),
|
||||
@@ -1305,8 +1300,6 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
|
||||
Err(error) => return mcp_tool_result(error, Vec::new(), true),
|
||||
};
|
||||
let redacted = redact_external_mcp_response(&content);
|
||||
let key = format!("{request_id}\u{0}{sequence}");
|
||||
let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes()));
|
||||
let guard = EXTERNAL_MCP_JOURNAL_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock();
|
||||
@@ -1421,29 +1414,6 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Reuse the existing conversation projection so the current UI can read
|
||||
// the explicit external response without treating it as business truth.
|
||||
if let Err(error) = append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: record
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&message_id,
|
||||
) {
|
||||
return mcp_tool_result(
|
||||
format!("Codex 返回已写入但对话投影失败:{error}"),
|
||||
Vec::new(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
records.push(record.clone());
|
||||
mcp_tool_result(record.to_string(), Vec::new(), false)
|
||||
}
|
||||
@@ -2252,16 +2222,6 @@ mod tests {
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "agc_generate_image")
|
||||
.expect("image tool");
|
||||
assert_eq!(
|
||||
image_tool["inputSchema"]["properties"]["kind"]["enum"],
|
||||
json!([
|
||||
"image",
|
||||
"character",
|
||||
"icon-spec",
|
||||
"ui-prototype",
|
||||
"publication-material"
|
||||
])
|
||||
);
|
||||
assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"]));
|
||||
assert!(image_tool["description"]
|
||||
.as_str()
|
||||
@@ -2337,6 +2297,27 @@ mod tests {
|
||||
assert!(!specs.to_string().contains("apiKey"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_image_rejects_kinds_outside_the_shared_catalog_before_the_bridge() {
|
||||
// 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。
|
||||
let unsupported_kind = call_agc_generate_image(&json!({
|
||||
"prompt": "像素月光主角",
|
||||
"kind": "game-art"
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(unsupported_kind["isError"], json!(true));
|
||||
assert!(unsupported_kind["content"][0]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("kind")));
|
||||
|
||||
let oversized_prompt = call_agc_generate_image(&json!({
|
||||
"prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1),
|
||||
"kind": "art-spritesheet"
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(oversized_prompt["isError"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() {
|
||||
assert!(validate_registered_assets_arguments(&json!({
|
||||
@@ -2628,4 +2609,33 @@ mod tests {
|
||||
}))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_codex_response_only_writes_its_own_journal() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "direct-tools-record", "Codex 返回记录测试")
|
||||
.expect("init project");
|
||||
|
||||
let result = external_mcp_record_response(
|
||||
root,
|
||||
&json!({
|
||||
"requestId": "req-1",
|
||||
"sequence": 0,
|
||||
"content": "已完成的返回正文"
|
||||
}),
|
||||
);
|
||||
assert_eq!(result.get("isError"), Some(&json!(false)), "{result}");
|
||||
|
||||
let journal = root.join(".agent/conversations/codex-responses.jsonl");
|
||||
let journal_text = std::fs::read_to_string(&journal).expect("read codex responses journal");
|
||||
assert!(journal_text.contains("已完成的返回正文"), "{journal_text}");
|
||||
|
||||
let project_history = root.join(".agent/conversations/project.jsonl");
|
||||
let project_history_text = std::fs::read_to_string(&project_history).unwrap_or_default();
|
||||
assert_eq!(
|
||||
project_history_text, "",
|
||||
"显式 Codex 返回不能再往项目主对话写 legacy 行"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,11 @@ pub(crate) use canvas_generation::{
|
||||
build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at,
|
||||
generate_platform_art_asset_with_options_at,
|
||||
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
|
||||
needs_platform_art_asset_generation, platform_art_asset_art_spec,
|
||||
platform_art_asset_output_extension_matches, prepare_platform_art_asset_output_path,
|
||||
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
||||
PlatformArtAssetGenerationOptions,
|
||||
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
|
||||
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
||||
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,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use draft_validation::{
|
||||
|
||||
@@ -429,6 +429,40 @@ impl Default for PlatformArtAssetGenerationOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「无源生成图片类素材」参数化通道放行的 kind 目录。
|
||||
///
|
||||
/// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录,
|
||||
/// 两条调用路径不允许各写一套白名单;目录外的 kind 一律拒绝,不做兜底猜测。
|
||||
///
|
||||
/// `spec` 只是平台图片生成的 generation kind 名称,客户端真正验证过的规范图类型是
|
||||
/// `icon-spec`:`build_platform_art_asset_prompt`、`platform_art_asset_art_spec` 与
|
||||
/// `canonical_art_spec_reference_at` 都只认 `icon-spec`,登记成 `spec` 的图既拿不到规范图
|
||||
/// 提示词,也无法作为 UI 原型与透明图集的权威参考。因此 `spec` 在
|
||||
/// `normalize_platform_art_asset_generation_kind` 里统一收口到 `icon-spec`。
|
||||
pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[&str] = &[
|
||||
"image",
|
||||
"character",
|
||||
"spec",
|
||||
"icon-spec",
|
||||
"ui-prototype",
|
||||
"art-spritesheet",
|
||||
"publication-material",
|
||||
];
|
||||
|
||||
/// 把外部传入的 kind 收口到 `PlatformArtAssetGenerationOptions::asset_kind` 的权威取值。
|
||||
/// 返回 `None` 表示该 kind 未被验证过,调用方必须拒绝。
|
||||
pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option<&'static str> {
|
||||
let kind = kind.trim();
|
||||
let canonical = PLATFORM_ART_ASSET_GENERATION_KINDS
|
||||
.iter()
|
||||
.find(|candidate| **candidate == kind)?;
|
||||
Some(if *canonical == "spec" {
|
||||
"icon-spec"
|
||||
} else {
|
||||
canonical
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn recover_persisted_visual_generation_options(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
@@ -6786,6 +6820,8 @@ fn register_platform_art_slice_manifest_entries_at(
|
||||
local_path: registration.local_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
ids.push(id);
|
||||
@@ -7824,6 +7860,69 @@ mod canvas_generation_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() {
|
||||
// 目录就是两条调用路径共同的可生成集合,必须逐字固定。
|
||||
assert_eq!(
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
&[
|
||||
"image",
|
||||
"character",
|
||||
"spec",
|
||||
"icon-spec",
|
||||
"ui-prototype",
|
||||
"art-spritesheet",
|
||||
"publication-material"
|
||||
]
|
||||
);
|
||||
for kind in ["image", "character", "ui-prototype", "art-spritesheet"] {
|
||||
assert_eq!(
|
||||
normalize_platform_art_asset_generation_kind(kind),
|
||||
Some(kind),
|
||||
"{kind} 必须原样落到 assetKind"
|
||||
);
|
||||
}
|
||||
// `spec` 只是服务端 generation kind;客户端验证过的规范图类型是 icon-spec,
|
||||
// 提示词与规范图引用都只认它,所以这里必须收口而不是放行原值。
|
||||
assert_eq!(
|
||||
normalize_platform_art_asset_generation_kind("spec"),
|
||||
Some("icon-spec")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_platform_art_asset_generation_kind("icon-spec"),
|
||||
Some("icon-spec")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_platform_art_asset_generation_kind(" art-spritesheet "),
|
||||
Some("art-spritesheet")
|
||||
);
|
||||
// 未验证的 kind 一律拒绝,不做兜底猜测。
|
||||
for kind in ["game-art", "game-background", "UI", "asset", "scene", ""] {
|
||||
assert_eq!(
|
||||
normalize_platform_art_asset_generation_kind(kind),
|
||||
None,
|
||||
"{kind} 不在已审核目录内,必须被拒绝"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
|
||||
// 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同,
|
||||
// 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
asset_kind: "art-spritesheet".to_string(),
|
||||
..PlatformArtAssetGenerationOptions::default()
|
||||
};
|
||||
assert!(
|
||||
build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集")
|
||||
);
|
||||
assert_eq!(
|
||||
platform_art_asset_art_spec(&options)["assetType"],
|
||||
serde_json::json!("art")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_http_error_summary_keeps_validation_details_without_sensitive_context() {
|
||||
let body = serde_json::json!({
|
||||
|
||||
@@ -1121,6 +1121,9 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
|
||||
let image = images
|
||||
.pop()
|
||||
.ok_or_else(|| "UI 原型图片读取结果为空".to_string())?;
|
||||
// 摘要缺失必须失败关闭:视觉检查审计按摘要证明「检查过的就是当前这张图」,
|
||||
// 不能退化成空摘要比较,否则一条 sha256 为空的记录就能通过复核。
|
||||
let image_sha256 = image.sha256_digest()?.to_string();
|
||||
let (records, scan_truncated) =
|
||||
read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?;
|
||||
let matching = records.iter().rev().find(|record| {
|
||||
@@ -1146,13 +1149,13 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
|
||||
&& items[0].get("path").and_then(serde_json::Value::as_str)
|
||||
== Some(expected_path)
|
||||
&& items[0].get("sha256").and_then(serde_json::Value::as_str)
|
||||
== Some(image.sha256.as_str())
|
||||
== Some(image_sha256.as_str())
|
||||
})
|
||||
});
|
||||
let Some(record) = matching else {
|
||||
return Ok(Some(format!(
|
||||
"expectedPath={expected_path} · currentSha256={} · requiredInspection=image.inspect · inspectionRunId={} · scanTruncated={scan_truncated}",
|
||||
image.sha256,
|
||||
image_sha256,
|
||||
required_run_id.unwrap_or("latest-current-image")
|
||||
)));
|
||||
};
|
||||
|
||||
@@ -429,16 +429,26 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect(
|
||||
.as_deref()
|
||||
.map(|value| sanitize_agent_runtime_text(value, 160))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let image_metadata = images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
serde_json::json!({
|
||||
"path": image.relative_path,
|
||||
"sha256": image.sha256,
|
||||
"bytes": image.byte_len,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// 视觉检查审计按 sha256 记录并复核「检查过的就是当前这张图」,摘要缺失必须失败关闭。
|
||||
let mut image_metadata = Vec::with_capacity(images.len());
|
||||
for image in &images {
|
||||
let sha256 = match image.sha256_digest() {
|
||||
Ok(sha256) => sha256,
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "image.inspect".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: sanitize_agent_runtime_text(&error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
image_metadata.push(serde_json::json!({
|
||||
"path": image.relative_path,
|
||||
"sha256": sha256,
|
||||
"bytes": image.byte_len,
|
||||
}));
|
||||
}
|
||||
let validation_profile =
|
||||
ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE);
|
||||
let passed = ui_prototype_assessment
|
||||
|
||||
@@ -467,6 +467,77 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
|
||||
active_external_editor_api_credentials().is_some()
|
||||
}
|
||||
|
||||
/// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。
|
||||
///
|
||||
/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到
|
||||
/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。
|
||||
///
|
||||
/// 更不能把 `ui` 当"图片的默认类型":`ui → ui-design → ui-interaction` 会把任意上传图片
|
||||
/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复
|
||||
/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified`
|
||||
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
|
||||
///
|
||||
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
|
||||
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
let extension = Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let extension = extension.as_str();
|
||||
if media_type.starts_with("audio/")
|
||||
|| matches!(
|
||||
extension,
|
||||
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
|
||||
)
|
||||
{
|
||||
"audio"
|
||||
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
|
||||
"video"
|
||||
} else if media_type.starts_with("font/")
|
||||
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
|
||||
{
|
||||
"document"
|
||||
} else if media_type.starts_with("image/")
|
||||
|| matches!(
|
||||
extension,
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
|
||||
)
|
||||
{
|
||||
"image"
|
||||
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|
||||
|| media_type.contains("javascript")
|
||||
|| media_type.contains("typescript")
|
||||
|| matches!(
|
||||
extension,
|
||||
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
|
||||
)
|
||||
{
|
||||
"code"
|
||||
} else if media_type.starts_with("text/")
|
||||
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|
||||
|| matches!(
|
||||
extension,
|
||||
"md" | "markdown"
|
||||
| "mdx"
|
||||
| "txt"
|
||||
| "json"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "toml"
|
||||
| "csv"
|
||||
| "ini"
|
||||
| "conf"
|
||||
| "xml"
|
||||
)
|
||||
{
|
||||
"document"
|
||||
} else {
|
||||
"asset"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn upload_local_asset_at(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
@@ -492,7 +563,7 @@ pub(crate) fn upload_local_asset_at(
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
&relative_path,
|
||||
"uploaded",
|
||||
uploaded_asset_kind(file_name, media_type),
|
||||
media_type,
|
||||
"upload",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -1699,27 +1770,35 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
|
||||
normalize_relative_path(&normalized)
|
||||
}
|
||||
|
||||
/// 画板导出图层推断出的资源 kind。
|
||||
///
|
||||
/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入
|
||||
/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的
|
||||
/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于
|
||||
/// 兼容存量数据,不作为新写入值的来源。
|
||||
pub(crate) fn infer_canvas_export_asset_kind(
|
||||
layer: &CanvasExportLayerMetadata,
|
||||
file: &str,
|
||||
) -> &'static str {
|
||||
let layer_type = layer.visible.layer_type.as_str();
|
||||
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
|
||||
return "animation";
|
||||
}
|
||||
if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
|
||||
return "audio";
|
||||
}
|
||||
if contains_any(layer_type, &["角色"]) {
|
||||
return "character";
|
||||
}
|
||||
if contains_any(layer_type, &["场景", "背景"]) {
|
||||
return "scene";
|
||||
}
|
||||
if contains_any(layer_type, &["UI", "界面", "图标"]) {
|
||||
return "ui";
|
||||
}
|
||||
"asset"
|
||||
let inferred = if file.starts_with("sequences/")
|
||||
|| contains_any(layer_type, &["序列", "动画", "动作"])
|
||||
{
|
||||
"character-animation"
|
||||
} else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"])
|
||||
{
|
||||
"audio"
|
||||
} else if contains_any(layer_type, &["角色"]) {
|
||||
"character"
|
||||
} else if contains_any(layer_type, &["场景", "背景"]) {
|
||||
"scene"
|
||||
} else if contains_any(layer_type, &["UI", "界面", "图标"]) {
|
||||
"ui-design"
|
||||
} else {
|
||||
"image"
|
||||
};
|
||||
// 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。
|
||||
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred)
|
||||
}
|
||||
|
||||
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
|
||||
@@ -1815,7 +1894,14 @@ pub(crate) fn register_local_asset_entry(
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == normalized_path)
|
||||
{
|
||||
existing.kind = kind.to_string();
|
||||
// kind 变了必须重派生 category:否则同路径重登记会把新 kind 和旧分类拼在一起,
|
||||
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
||||
// 时才触发),于是这个资产永远停在错误栏目。
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update"))
|
||||
@@ -1832,6 +1918,8 @@ pub(crate) fn register_local_asset_entry(
|
||||
local_path: normalized_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(kind),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
Ok((id, "asset.register"))
|
||||
@@ -1857,11 +1945,298 @@ pub(crate) fn register_local_asset_entry(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectAssetReferencesInput {
|
||||
pub project_path: String,
|
||||
pub asset_id: String,
|
||||
}
|
||||
|
||||
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectAssetReferenceVersion {
|
||||
pub version_id: String,
|
||||
pub project_revision: u64,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ReadLocalProjectAssetReferencesResult {
|
||||
pub asset_id: String,
|
||||
pub versions: Vec<LocalProjectAssetReferenceVersion>,
|
||||
}
|
||||
|
||||
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
|
||||
///
|
||||
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
|
||||
fn asset_referencing_versions(
|
||||
manifest: &GameCreationAppManifest,
|
||||
asset_id: &str,
|
||||
) -> Vec<LocalProjectAssetReferenceVersion> {
|
||||
manifest
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
version
|
||||
.resource_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.resource_id == asset_id)
|
||||
})
|
||||
.map(|version| LocalProjectAssetReferenceVersion {
|
||||
version_id: version.version_id.clone(),
|
||||
project_revision: version.project_revision,
|
||||
created_at: version.created_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("素材 assetId 不能为空".to_string());
|
||||
}
|
||||
Ok(asset_id)
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest_asset_references_at(
|
||||
root: &Path,
|
||||
asset_id: &str,
|
||||
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
|
||||
let asset_id = normalized_asset_id(asset_id)?;
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
Ok(ReadLocalProjectAssetReferencesResult {
|
||||
asset_id: asset_id.to_string(),
|
||||
versions: asset_referencing_versions(&manifest, asset_id),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteLocalProjectAssetInput {
|
||||
pub project_path: String,
|
||||
pub expected_project_id: String,
|
||||
pub expected_project_revision: u64,
|
||||
pub asset_id: String,
|
||||
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
|
||||
pub delete_referenced_versions: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeleteLocalProjectAssetResult {
|
||||
pub asset_id: String,
|
||||
pub local_path: String,
|
||||
pub committed_project_revision: u64,
|
||||
/// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。
|
||||
pub file_retained: bool,
|
||||
}
|
||||
|
||||
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
|
||||
///
|
||||
/// 语义:
|
||||
/// - 未被任何版本引用 → 只摘掉登记;
|
||||
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
|
||||
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
|
||||
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
|
||||
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
|
||||
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS,
|
||||
/// 失败时 manifest 与 revision 都不变。
|
||||
/// - **幂等**:CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision
|
||||
/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision
|
||||
/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。
|
||||
pub(crate) fn delete_manifest_asset_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
expected_project_revision: u64,
|
||||
asset_id: &str,
|
||||
delete_referenced_versions: bool,
|
||||
) -> Result<DeleteLocalProjectAssetResult, String> {
|
||||
if expected_project_revision
|
||||
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
|
||||
{
|
||||
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
|
||||
}
|
||||
let expected_project_id = expected_project_id.trim();
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("素材删除 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
let asset_id = normalized_asset_id(asset_id)?;
|
||||
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
|
||||
{
|
||||
return Err("project-revision-conflict".to_string());
|
||||
}
|
||||
|
||||
let mut local_path = String::new();
|
||||
mutate_manifest_at_allowing_version_removals(
|
||||
root,
|
||||
&|manifest| {
|
||||
if !delete_referenced_versions {
|
||||
return Vec::new();
|
||||
}
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
.collect()
|
||||
},
|
||||
|manifest| {
|
||||
let Some(index) = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.position(|asset| asset.id == asset_id)
|
||||
else {
|
||||
// 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败
|
||||
// (manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与
|
||||
// `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当
|
||||
// no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。
|
||||
// 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。
|
||||
return Ok(());
|
||||
};
|
||||
local_path = manifest.assets[index].local_path.clone();
|
||||
manifest.assets.remove(index);
|
||||
if delete_referenced_versions {
|
||||
// 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains`
|
||||
// 在引用该素材的版本很多时是 O(V²)。
|
||||
// (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次,
|
||||
// 它拿不到这里的可变借用,所以两次扫描无法合并。)
|
||||
let referenced_version_ids: std::collections::HashSet<String> =
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
.collect();
|
||||
manifest
|
||||
.versions
|
||||
.retain(|version| !referenced_version_ids.contains(&version.version_id));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
|
||||
|
||||
Ok(DeleteLocalProjectAssetResult {
|
||||
asset_id: asset_id.to_string(),
|
||||
local_path,
|
||||
committed_project_revision,
|
||||
file_retained: true,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
/// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护
|
||||
/// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。
|
||||
#[test]
|
||||
fn canvas_export_asset_kind_is_always_canonical() {
|
||||
fn layer(layer_type: &str) -> CanvasExportLayerMetadata {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"title": "测试图层",
|
||||
"file": "layer.png",
|
||||
"exportError": null,
|
||||
"visible": {
|
||||
"type": layer_type,
|
||||
"model": "-",
|
||||
"task": "-",
|
||||
"object": "-",
|
||||
},
|
||||
}))
|
||||
.expect("canvas export layer metadata")
|
||||
}
|
||||
|
||||
// 每个分支的代表输入 → 期望的 canonical kind。
|
||||
let cases: [(&str, &str, &str); 6] = [
|
||||
("序列", "sequences/01.png", "character-animation"),
|
||||
("动画", "layer.png", "character-animation"),
|
||||
("音频", "layer.png", "audio"),
|
||||
("角色", "layer.png", "character"),
|
||||
("场景", "layer.png", "scene"),
|
||||
("UI", "layer.png", "ui-design"),
|
||||
// 无匹配时落到 image。
|
||||
];
|
||||
for (layer_type, file, expected) in cases {
|
||||
let kind = infer_canvas_export_asset_kind(&layer(layer_type), file);
|
||||
assert_eq!(kind, expected, "layer_type={layer_type} file={file}");
|
||||
}
|
||||
// media/ 前缀同样走音频分支。
|
||||
assert_eq!(
|
||||
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
|
||||
"audio"
|
||||
);
|
||||
// 兜底分支是 image 而不是旧的 "asset"。
|
||||
assert_eq!(
|
||||
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
|
||||
"image"
|
||||
);
|
||||
|
||||
// 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。
|
||||
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
|
||||
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
|
||||
assert!(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
|
||||
.contains(&kind),
|
||||
"非 canonical kind: {kind}(layer_type={layer_type})"
|
||||
);
|
||||
for legacy in ["ui", "animation", "asset"] {
|
||||
assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型),
|
||||
/// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片
|
||||
/// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。
|
||||
///
|
||||
/// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。
|
||||
#[test]
|
||||
fn uploaded_asset_kind_uses_content_evidence_and_never_ui() {
|
||||
assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image");
|
||||
assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image");
|
||||
assert_eq!(uploaded_asset_kind("bg.webp", ""), "image");
|
||||
assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio");
|
||||
assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video");
|
||||
assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document");
|
||||
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document");
|
||||
assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code");
|
||||
// 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。
|
||||
assert_eq!(
|
||||
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
|
||||
"asset"
|
||||
);
|
||||
assert_eq!(uploaded_asset_kind("no-extension", ""), "asset");
|
||||
// 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。
|
||||
for (file_name, media_type) in [
|
||||
("hero.png", "image/png"),
|
||||
("theme.mp3", "audio/mpeg"),
|
||||
("unknown.bin", ""),
|
||||
] {
|
||||
let kind = uploaded_asset_kind(file_name, media_type);
|
||||
assert_ne!(kind, "uploaded", "{file_name} 写回了来源词");
|
||||
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
|
||||
assert!(
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
|
||||
.contains(&kind)
|
||||
|| kind == "asset",
|
||||
"非 canonical kind: {kind}({file_name})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_file_extension_preserves_supported_local_resource_extensions() {
|
||||
assert_eq!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,10 @@ pub(crate) struct LocalProjectImagePreview {
|
||||
|
||||
pub(crate) struct AgentRuntimeInspectionImage {
|
||||
pub(crate) relative_path: String,
|
||||
pub(crate) sha256: String,
|
||||
/// 只有 Agent 视觉检查(`image.inspect`)需要内容摘要:它要把摘要写进动作审计并按摘要
|
||||
/// 复核「检查过的就是当前这张图」。资源卡预览只需要字节、媒体类型和像素尺寸,摘要没有
|
||||
/// 任何消费者,因此预览路径不计算它。取摘要必须走 [`Self::sha256_digest`],缺摘要即失败关闭。
|
||||
pub(crate) sha256: Option<String>,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) media_type: &'static str,
|
||||
pixel_width: u32,
|
||||
@@ -48,6 +51,15 @@ impl AgentRuntimeInspectionImage {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn sha256_digest(&self) -> Result<&str, String> {
|
||||
self.sha256.as_deref().ok_or_else(|| {
|
||||
format!(
|
||||
"image.inspect 缺少内容摘要:{};摘要只在视觉检查读取路径上计算",
|
||||
self.relative_path
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn data_url_with_cancellation(
|
||||
&self,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
@@ -83,8 +95,12 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
|
||||
let absolute = resolve_local_project_path(root, &normalized)?;
|
||||
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
|
||||
cancellation.check()?;
|
||||
let image =
|
||||
read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?;
|
||||
let image = read_agent_runtime_inspection_image_with_cancellation(
|
||||
&absolute,
|
||||
normalized,
|
||||
cancellation,
|
||||
false,
|
||||
)?;
|
||||
cancellation.check()?;
|
||||
Ok(LocalProjectImagePreview {
|
||||
path: image.relative_path.clone(),
|
||||
@@ -273,13 +289,21 @@ fn read_agent_runtime_inspection_image(
|
||||
path,
|
||||
relative_path,
|
||||
&ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// 单次受控读取:门禁、分块读取、漂移与替换复核、签名 / 尺寸校验对两条调用方完全一致,
|
||||
/// 只有内容摘要按 `include_sha256` 分叉。
|
||||
///
|
||||
/// `include_sha256 = true` 是 Agent 视觉检查路径(`image.inspect`):摘要要写进动作审计,并在
|
||||
/// `project_gates` 里按摘要复核「检查过的就是当前这张图」。资源卡预览路径传 `false`,因为它的
|
||||
/// `LocalProjectImagePreview` 从来不消费摘要,只为它多算一遍全长 SHA-256 是纯开销。
|
||||
fn read_agent_runtime_inspection_image_with_cancellation(
|
||||
path: &Path,
|
||||
relative_path: String,
|
||||
cancellation: &ProjectResourcePreviewScopeCancellation,
|
||||
include_sha256: bool,
|
||||
) -> Result<AgentRuntimeInspectionImage, String> {
|
||||
cancellation.check()?;
|
||||
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
|
||||
@@ -360,7 +384,7 @@ fn read_agent_runtime_inspection_image_with_cancellation(
|
||||
));
|
||||
}
|
||||
cancellation.check()?;
|
||||
let sha256 = format!("{:x}", Sha256::digest(&bytes));
|
||||
let sha256 = include_sha256.then(|| format!("{:x}", Sha256::digest(&bytes)));
|
||||
Ok(AgentRuntimeInspectionImage {
|
||||
relative_path,
|
||||
sha256,
|
||||
@@ -582,17 +606,40 @@ fn runtime_path_component(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
|
||||
pub(crate) fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
|
||||
pub(crate) fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 已打开句柄的跨平台文件身份:Windows 用 `(volume serial, file index)`,Unix 用 `(dev, ino)`。
|
||||
/// 资源卡预览的 manifest 缓存用它判断「路径指向的还是同一个文件」。
|
||||
pub(crate) fn open_file_identity_key(file: &fs::File) -> Result<(u64, u64), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let (volume_serial_number, file_index) = windows_file_identity(file)?;
|
||||
Ok((u64::from(volume_serial_number), file_index))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("读取文件身份元数据失败:{error}"))?;
|
||||
Ok((metadata.dev(), metadata.ino()))
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = file;
|
||||
Err("当前平台不支持文件身份判定".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
@@ -26,7 +26,9 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
use shared_contracts::error_reports::ErrorReportLogInput;
|
||||
use shared_contracts::game_creation_app::{
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
|
||||
game_creation_app_asset_effective_category, new_game_creation_app_manifest,
|
||||
new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags,
|
||||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
@@ -2227,6 +2229,13 @@ where
|
||||
|
||||
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
// 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json`
|
||||
// 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。
|
||||
if let Err(error) =
|
||||
preview::stop_local_game_preview_on_exit(&game_creator_preview_registry())
|
||||
{
|
||||
app_log!("preview.gui_exit.stop_failed: {error}");
|
||||
}
|
||||
if let Err(error) = agent::shutdown_game_creator_codex_app_servers() {
|
||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
}
|
||||
@@ -2592,6 +2601,7 @@ fn main() {
|
||||
pick_local_project_directory,
|
||||
rename_local_game_project,
|
||||
suggest_automatic_project_name,
|
||||
polish_local_project_prompt,
|
||||
pick_local_file,
|
||||
pick_client_extension_file,
|
||||
pick_client_extension_directory,
|
||||
@@ -2662,6 +2672,7 @@ fn main() {
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
update_local_project_resource_classification,
|
||||
derive_local_project_resource,
|
||||
list_pending_local_project_resource_edits,
|
||||
resume_local_project_resource_edit,
|
||||
@@ -2685,6 +2696,7 @@ fn main() {
|
||||
generate_ui_design_code,
|
||||
ensure_ui_design_resource_for_prototype,
|
||||
generate_platform_art_asset,
|
||||
generate_local_project_asset,
|
||||
open_canvas_project,
|
||||
get_game_creation_agent_capabilities,
|
||||
get_limited_local_commands,
|
||||
@@ -2694,6 +2706,7 @@ fn main() {
|
||||
import_local_project_image_assets,
|
||||
read_local_project_file,
|
||||
read_local_project_image_preview,
|
||||
save_local_project_asset_file,
|
||||
read_local_project_text_preview,
|
||||
read_local_project_media_preview,
|
||||
cancel_local_project_resource_preview_scope,
|
||||
@@ -2732,24 +2745,11 @@ fn main() {
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
create_local_project_asset_canvas_draft,
|
||||
read_local_project_asset_canvas_draft,
|
||||
discover_local_project_asset_canvas_draft,
|
||||
update_local_project_asset_canvas_draft,
|
||||
acknowledge_local_project_asset_canvas_candidate_layers,
|
||||
import_local_project_asset_canvas_images,
|
||||
store_local_project_asset_canvas_media,
|
||||
stage_local_project_asset_canvas_image,
|
||||
finalize_local_project_asset_canvas_generation_failure,
|
||||
archive_failed_local_project_asset_canvas_generation,
|
||||
generate_local_project_asset_canvas_image,
|
||||
recover_local_project_asset_canvas_generations,
|
||||
confirm_local_project_asset_canvas_generation_service_identity,
|
||||
read_local_project_asset_canvas_media,
|
||||
discard_local_project_asset_canvas_draft,
|
||||
recover_local_project_asset_canvas_transactions,
|
||||
commit_local_project_asset,
|
||||
commit_local_project_asset_canvas_candidate,
|
||||
delete_local_project_asset,
|
||||
read_local_project_asset_references,
|
||||
rename_local_project_asset,
|
||||
read_local_project_version_resource_replacement_candidates,
|
||||
replace_local_project_version_resource,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest,
|
||||
download_agc_update,
|
||||
|
||||
@@ -488,6 +488,113 @@ const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
|
||||
if (!document.hidden) schedule();
|
||||
}, 500);
|
||||
};
|
||||
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
|
||||
const inspectState = { enabled: false, overlay: null, current: null };
|
||||
const inspectOverlay = () => {
|
||||
if (inspectState.overlay) return inspectState.overlay;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.dataset.genarrativePreviewInspect = 'true';
|
||||
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
|
||||
(document.body || document.documentElement).appendChild(overlay);
|
||||
inspectState.overlay = overlay;
|
||||
return overlay;
|
||||
};
|
||||
const clearInspectOverlay = () => {
|
||||
inspectState.overlay?.remove();
|
||||
inspectState.overlay = null;
|
||||
inspectState.current = null;
|
||||
};
|
||||
const inspectElementSummary = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
|
||||
const role = (element.getAttribute && element.getAttribute('role')) || '';
|
||||
const text = (element.innerText || element.textContent || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
const resourceIds = [];
|
||||
if (element.getAttribute) {
|
||||
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
|
||||
const value = element.getAttribute(name);
|
||||
if (value && !resourceIds.includes(value)) resourceIds.push(value);
|
||||
}
|
||||
}
|
||||
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
|
||||
const rawSource =
|
||||
(element.getAttribute && element.getAttribute('src')) ||
|
||||
(media && media.getAttribute && media.getAttribute('src')) ||
|
||||
'';
|
||||
let sourcePath = '';
|
||||
if (rawSource) {
|
||||
try {
|
||||
sourcePath = new URL(rawSource, window.location.href).pathname;
|
||||
} catch {
|
||||
sourcePath = '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: text || tag,
|
||||
elementTag: tag,
|
||||
elementRole: role.slice(0, 80),
|
||||
text,
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
resourceIds,
|
||||
sourcePath,
|
||||
};
|
||||
};
|
||||
const postInspect = (action, selection) => {
|
||||
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
|
||||
};
|
||||
const stopInspect = (cancelled) => {
|
||||
if (!inspectState.enabled) return;
|
||||
inspectState.enabled = false;
|
||||
document.removeEventListener('mousemove', handleInspectMove, true);
|
||||
document.removeEventListener('click', handleInspectClick, true);
|
||||
document.removeEventListener('keydown', handleInspectKeyDown, true);
|
||||
clearInspectOverlay();
|
||||
postInspect(cancelled ? 'cancelled' : 'disabled');
|
||||
};
|
||||
const handleInspectMove = (event) => {
|
||||
if (!inspectState.enabled) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
inspectState.current = target;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const overlay = inspectOverlay();
|
||||
overlay.style.left = `${rect.left}px`;
|
||||
overlay.style.top = `${rect.top}px`;
|
||||
overlay.style.width = `${rect.width}px`;
|
||||
overlay.style.height = `${rect.height}px`;
|
||||
};
|
||||
const handleInspectClick = (event) => {
|
||||
if (!inspectState.enabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = event.target;
|
||||
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
|
||||
stopInspect(false);
|
||||
};
|
||||
const handleInspectKeyDown = (event) => {
|
||||
if (!inspectState.enabled || event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
stopInspect(true);
|
||||
};
|
||||
const startInspect = () => {
|
||||
if (inspectState.enabled) return;
|
||||
inspectState.enabled = true;
|
||||
document.addEventListener('mousemove', handleInspectMove, true);
|
||||
document.addEventListener('click', handleInspectClick, true);
|
||||
document.addEventListener('keydown', handleInspectKeyDown, true);
|
||||
postInspect('enabled');
|
||||
};
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window.parent) return;
|
||||
const data = event.data;
|
||||
if (!data || data.type !== inspectMessageType) return;
|
||||
if (data.action === 'enable') startInspect();
|
||||
else if (data.action === 'disable') stopInspect(false);
|
||||
});
|
||||
if (ready) activate();
|
||||
else window.addEventListener('load', activate, { once: true });
|
||||
})();"#;
|
||||
@@ -651,7 +758,14 @@ pub(crate) fn start_local_game_preview_at_revision(
|
||||
}
|
||||
let (preview, previous_preview) = registry.set_running(preview, stop);
|
||||
if let Some(previous_preview) = previous_preview.as_ref() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
// 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享
|
||||
// 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖
|
||||
// 成 stopped,下次进项目便丢掉"预览在跑"这条事实。只有旧预览属于**另一个项目**
|
||||
// 时才替它收尾(那才是真的换项目)。
|
||||
let previous_status = local_preview_status_from_result(previous_preview);
|
||||
if ensure_preview_belongs_to_project(&previous_status, root).is_err() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
}
|
||||
}
|
||||
if let Err(error) = append_preview_start_trace_step(root, &preview) {
|
||||
let _ = registry.stop();
|
||||
@@ -685,14 +799,44 @@ pub(crate) fn stop_local_game_preview_for_root(
|
||||
registry: &PreviewRegistry,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
let (status, stopped) = registry.stop_for_project(root);
|
||||
if let Some(root) = root.filter(|_| stopped) {
|
||||
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
|
||||
append_preview_log(root, "stopped", None)?;
|
||||
append_preview_stop_trace_step(root)?;
|
||||
let Some(root) = root else {
|
||||
return Ok(status);
|
||||
};
|
||||
// 没有活体预览可停时,落盘记录仍可能是陈旧的 running:预览服务器是进程内线程、
|
||||
// 非 Linux 端口还是随机临时端口,客户端退出后记录会一直写着 running。这时把记录
|
||||
// 对齐成 stopped 才算真的"停"——否则下次进项目会照着陈旧记录渲染打不开的运行
|
||||
// 界面(进入项目时的活体核对是第二道保险,记录本身也该在自己的生命周期里结束)。
|
||||
let stale_record = !stopped
|
||||
&& read_manifest_for_project(root)?
|
||||
.preview
|
||||
.is_some_and(|preview| preview.status == GameCreationAppPreviewStatus::Running);
|
||||
if !stopped && !stale_record {
|
||||
return Ok(status);
|
||||
}
|
||||
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
|
||||
append_preview_log(root, "stopped", None)?;
|
||||
append_preview_stop_trace_step(root)?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// 退出路径:把当前预览真正停掉,并把落盘记录对齐成 stopped。
|
||||
///
|
||||
/// 客户端退出是"记录与真相分叉"的主要来源(进程内监听线程随进程消失,记录却留着
|
||||
/// running),所以退出时统一收尾。进入项目时的活体核对仍然保留:进程被强杀、断电这类
|
||||
/// 不走退出路径的情况,靠那道核对自愈。
|
||||
pub(crate) fn stop_local_game_preview_on_exit(registry: &PreviewRegistry) -> Result<(), String> {
|
||||
let status = registry.status();
|
||||
if status.status != "running" {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(root) = status.root.as_deref() else {
|
||||
// running 却没有 root 不该出现:只把 listener 停掉,不猜记录该写到哪个项目。
|
||||
registry.stop();
|
||||
return Ok(());
|
||||
};
|
||||
stop_local_game_preview_for_root(Some(Path::new(root)), registry).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn stop_local_game_preview_if_matches(
|
||||
project_path: String,
|
||||
|
||||
@@ -4,7 +4,8 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_canvas;
|
||||
mod asset_export;
|
||||
mod asset_rename;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
@@ -16,10 +17,12 @@ mod resource_dependency_graph;
|
||||
mod resource_editor;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
mod version_resource_replacement;
|
||||
mod write_lock;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_canvas::*;
|
||||
pub(crate) use asset_export::*;
|
||||
pub(crate) use asset_rename::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
@@ -31,4 +34,5 @@ pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_editor::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
pub(crate) use version_resource_replacement::*;
|
||||
pub(crate) use write_lock::*;
|
||||
|
||||
@@ -3537,6 +3537,16 @@ pub(crate) struct ProjectAppendGuard<'a> {
|
||||
_os_lock: File,
|
||||
}
|
||||
|
||||
/// append 锁的等待口径与项目写锁对齐:`agent/runtime_actions/project_gates.rs:1821-1823`
|
||||
/// 是 5ms × 2000(≈10s 完整窗口)/ 200(≈1s 短窗口),这里曾经是 10ms × 100 ≈ 1s。
|
||||
const PROJECT_APPEND_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5);
|
||||
const PROJECT_APPEND_LOCK_WAIT_ATTEMPTS: usize = 2_000;
|
||||
const PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
|
||||
|
||||
/// 锁超时文案里的稳定片段:控制流(DirectProject 失败提示、收尾重试判据)只认这个常量,
|
||||
/// 不各自复制一份中文(改一次文案等于改一次重试语义)。
|
||||
pub(crate) const PROJECT_APPEND_LOCK_TIMEOUT_MARKER: &str = "跨进程锁超时";
|
||||
|
||||
impl ProjectAppendLock {
|
||||
fn lock_process(&self, error_label: &str) -> Result<std::sync::MutexGuard<'_, ()>, String> {
|
||||
self.process_lock
|
||||
@@ -3544,17 +3554,65 @@ impl ProjectAppendLock {
|
||||
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
|
||||
}
|
||||
|
||||
/// 追加写入口,走完整等待窗口(≈10s)。这条等待的失败会直接变成用户可见的
|
||||
/// "写历史失败",而持锁方可能正在做一件 O(文件大小) 的事(整份读历史)甚至等 UAC,
|
||||
/// 1s 的窗口在这种对手面前必然打满。
|
||||
pub(crate) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
|
||||
let process_guard = self
|
||||
.process_lock
|
||||
.lock()
|
||||
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?;
|
||||
let os_lock = acquire_project_append_os_lock(&self.os_lock_path, error_label)?;
|
||||
self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_WAIT_ATTEMPTS)
|
||||
}
|
||||
|
||||
/// 只读入口,沿用短窗口(≈1s):调用方本来就会重跑(面板刷新、下一轮 prompt 组装),
|
||||
/// 长时间阻塞只会把它一起拖住。
|
||||
pub(crate) fn lock_short(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
|
||||
self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS)
|
||||
}
|
||||
|
||||
fn lock_with_attempts(
|
||||
&self,
|
||||
error_label: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<ProjectAppendGuard<'_>, String> {
|
||||
let process_guard = self.try_lock_process(error_label, max_attempts)?;
|
||||
let os_lock =
|
||||
acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?;
|
||||
Ok(ProjectAppendGuard {
|
||||
_process_guard: process_guard,
|
||||
_os_lock: os_lock,
|
||||
})
|
||||
}
|
||||
|
||||
/// 进程内锁也按**有界**等待取,窗口与调用方自己的窗口同长。
|
||||
///
|
||||
/// 这里曾经是无上限的 `lock()`:写者拿着进程内锁把 10s 的跨进程等待窗口走完,同一个进程里的
|
||||
/// `lock_short` 读者就一直卡在 `process_lock.lock()` 上 —— "短窗口让读路径保持可响应"只在
|
||||
/// 跨进程成立,同进程内并不成立。
|
||||
///
|
||||
/// 顺序不变:进程内锁**永远先于**跨进程锁取,且从不在持跨进程锁时回头取进程内锁,
|
||||
/// 因此不存在 ABBA。拿不到时复用 `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`,调用方既有的
|
||||
/// "争用可重试"判据照样成立。
|
||||
fn try_lock_process(
|
||||
&self,
|
||||
error_label: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<std::sync::MutexGuard<'_, ()>, String> {
|
||||
let max_attempts = max_attempts.max(1);
|
||||
for attempt in 0..max_attempts {
|
||||
match self.process_lock.try_lock() {
|
||||
Ok(guard) => return Ok(guard),
|
||||
Err(std::sync::TryLockError::Poisoned(_)) => {
|
||||
return Err(format!("获取{error_label}进程内锁失败:锁已损坏"));
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
if attempt + 1 < max_attempts {
|
||||
thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:同一个客户端进程内还有一次追加写未结束(进程内锁等待已达上限)"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
|
||||
@@ -3606,21 +3664,139 @@ fn project_append_os_lock_path(path: &Path) -> Result<PathBuf, String> {
|
||||
.join(format!("{}.lock", &fingerprint[..32])))
|
||||
}
|
||||
|
||||
fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result<File, String> {
|
||||
fn acquire_project_append_os_lock(
|
||||
path: &Path,
|
||||
error_label: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<File, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_game_creator_private_directory_tree(parent, error_label)?;
|
||||
prepare_game_creator_private_path_for_read(parent, true, error_label)?;
|
||||
}
|
||||
prepare_game_creator_private_path_for_read(path, false, error_label)?;
|
||||
for attempt in 0..100 {
|
||||
if let Some(file) = try_open_project_append_os_lock(path, error_label)? {
|
||||
let max_attempts = max_attempts.max(1);
|
||||
for attempt in 0..max_attempts {
|
||||
if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? {
|
||||
refresh_project_append_lock_diagnostic(&mut file, path, error_label);
|
||||
return Ok(file);
|
||||
}
|
||||
if attempt < 99 {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
if attempt + 1 < max_attempts {
|
||||
thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL);
|
||||
}
|
||||
}
|
||||
Err(format!("获取{error_label}跨进程锁超时:{}", path.display()))
|
||||
Err(format!(
|
||||
"获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:{}({})",
|
||||
path.display(),
|
||||
project_append_lock_holder_diagnostic(path)
|
||||
))
|
||||
}
|
||||
|
||||
/// 超时时报出"谁在持锁",只读锁文件里的诊断元数据。
|
||||
///
|
||||
/// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是
|
||||
/// 一句"检查运行时配置",那既不是现象也不是动作。
|
||||
fn project_append_lock_holder_diagnostic(path: &Path) -> String {
|
||||
// Windows 上持锁方把锁文件本身按 zero-share 打开(`try_open_project_append_os_lock` 里的
|
||||
// `share_mode(0)`),别的进程连"读"都拿不到它,所以先读与锁同目录、按普通共享方式写出的
|
||||
// 旁路诊断文件。旁路读不到再退回锁文件本身:Unix 一直是这么读的,而且老版本客户端持锁时
|
||||
// 也只有锁文件里有线索。两个来源都读不到才算"身份不可读"。
|
||||
if let Ok(content) = fs::read_to_string(project_append_lock_holder_record_path(path)) {
|
||||
return describe_project_append_lock_holder(&content);
|
||||
}
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string();
|
||||
};
|
||||
describe_project_append_lock_holder(&content)
|
||||
}
|
||||
|
||||
fn describe_project_append_lock_holder(content: &str) -> String {
|
||||
let Ok(record) = serde_json::from_str::<serde_json::Value>(content) else {
|
||||
return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string();
|
||||
};
|
||||
let pid = record.get("pid").and_then(serde_json::Value::as_u64);
|
||||
let label = record.get("label").and_then(serde_json::Value::as_str);
|
||||
let started_at = record
|
||||
.get("processStartedAt")
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
match (pid, label, started_at) {
|
||||
(Some(pid), Some(label), Some(started_at)) => {
|
||||
format!("持锁方 pid={pid}(进程启动于 {started_at},取锁用途 {label})")
|
||||
}
|
||||
(Some(pid), Some(label), None) => format!("持锁方 pid={pid}(取锁用途 {label})"),
|
||||
(Some(pid), None, _) => format!("持锁方 pid={pid}"),
|
||||
(None, ..) => "持锁方身份不可读:锁文件里没有 pid".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 旁路诊断文件:与锁同级、普通共享写入,专门给"锁文件本身读不到"的平台用。
|
||||
///
|
||||
/// 它是**只读诊断**,不参与判活 / 回收 / 抢占:拿它永远拿不到锁,锁所有权始终是那个
|
||||
/// zero-share 句柄。写入失败也不影响取锁结果(见 [`refresh_project_append_lock_diagnostic`])。
|
||||
fn project_append_lock_holder_record_path(path: &Path) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("append.lock");
|
||||
path.with_file_name(format!("{file_name}.holder.json"))
|
||||
}
|
||||
|
||||
/// 把"谁在持这把锁"写进锁文件与旁路诊断文件,供事后排障;同一进程重复取同一把锁时不重复写。
|
||||
///
|
||||
/// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与
|
||||
/// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md
|
||||
/// 2026-09-09「项目写锁残留回收与启动诊断」)。
|
||||
fn refresh_project_append_lock_diagnostic(file: &mut File, path: &Path, error_label: &str) {
|
||||
let pid = std::process::id();
|
||||
let mut current = String::new();
|
||||
let lock_file_already_records_this_process = file.seek(SeekFrom::Start(0)).is_ok()
|
||||
&& std::io::Read::read_to_string(file, &mut current).is_ok()
|
||||
&& serde_json::from_str::<serde_json::Value>(¤t)
|
||||
.ok()
|
||||
.and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64))
|
||||
== Some(u64::from(pid));
|
||||
let Ok(serialized) = serde_json::to_string(&serde_json::json!({
|
||||
"acquiredAt": unix_timestamp(),
|
||||
"label": error_label,
|
||||
"pid": pid,
|
||||
"processStartedAt": crate::project::project_write_lock_process_start_time_seconds(
|
||||
u64::from(pid)
|
||||
),
|
||||
})) else {
|
||||
return;
|
||||
};
|
||||
if !lock_file_already_records_this_process {
|
||||
if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = file.write_all(serialized.as_bytes());
|
||||
let _ = file.flush();
|
||||
}
|
||||
// 旁路文件每次取锁都刷新(不跟着上面的"本进程已写过"短路):上一次可能就是写它失败的那次。
|
||||
// 失败静默:它只是排障线索,不能影响取锁结果。
|
||||
let _ = fs::write(
|
||||
project_append_lock_holder_record_path(path),
|
||||
serialized.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。
|
||||
///
|
||||
/// 用与生产完全同一套打开方式(Windows 零共享句柄 / Unix `flock(LOCK_EX|LOCK_NB)`)去探同一
|
||||
/// 把锁文件:拿得到 ⇒ 没人持锁;拿不到 ⇒ 有人持锁。锁文件尚未创建同样算"没人持锁"(这里
|
||||
/// 刻意不创建它,探针不留副作用)。探针拿到的句柄立刻释放。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn project_append_os_lock_is_held_for_test(path: &Path) -> Result<bool, String> {
|
||||
let lock_path = project_append_os_lock_path(path)?;
|
||||
if !lock_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
match try_open_project_append_os_lock(&lock_path, "追加写锁探针")? {
|
||||
Some(file) => {
|
||||
drop(file);
|
||||
Ok(false)
|
||||
}
|
||||
None => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -3694,6 +3870,32 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放锁句柄之后才执行可能弹 UAC 的 ACL 提权修复。
|
||||
///
|
||||
/// `held_lock` 按值接收刚打开(因而正被持有)的锁句柄:调用点必须把所有权交出来,于是
|
||||
/// "一边持锁一边等 UAC" 在这个签名下无法被表达。函数体第一件事就是 `drop`,之后才提权;
|
||||
/// 修复成功后调用方返回"这轮没取到锁",由外层重试循环重新打开,按修复后的 DACL 通过严格校验。
|
||||
#[cfg(windows)]
|
||||
fn release_project_append_os_lock_then_repair_acl(
|
||||
held_lock: File,
|
||||
path: &Path,
|
||||
strict_error: &str,
|
||||
) -> Result<(), String> {
|
||||
drop(held_lock);
|
||||
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
|
||||
.map_err(|repair_error| {
|
||||
// 只有"可能靠提权修好"的错误才配得上这句后缀。非提权类失败(锁文件在打开与严格校验
|
||||
// 之间被删掉、ACL 报错不在提权口径内)时,自动修复没有做任何事就原样返回了同一个
|
||||
// 错误,再贴一句"自动提权修复未完成"等于把同一条错误报两遍,还把现场指向一个
|
||||
// 根本没发生过的动作。
|
||||
if crate::config::windows_acl_error_may_need_elevation(&repair_error) {
|
||||
format!("{strict_error};自动提权修复未完成:{repair_error}")
|
||||
} else {
|
||||
strict_error.to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Option<File>, String> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
@@ -3713,15 +3915,23 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
|
||||
Ok(file) => {
|
||||
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
|
||||
if !existed {
|
||||
// 自建对象的本地收紧不会触发 UAC(既有口径:本进程新建的 Windows 私有对象
|
||||
// 不因继承 DACL 自动提权),所以它留在持锁期间;**提权修复**不留在这里。
|
||||
if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(path);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
path, false, true,
|
||||
)?;
|
||||
// 严格校验留在持锁期间(对象就是刚打开的这个句柄),**修复移到锁外**:提权修复会
|
||||
// `Start-Process -Verb RunAs -Wait` 同步等用户点 UAC,在这里等它等于把"用户犹豫的
|
||||
// 时间"记进别人的持锁窗口(1s 预算必然打满,而且只有 Windows 有这条路径)。
|
||||
if let Err(error) =
|
||||
crate::secure_windows_game_creator_path_for_current_user(path, false, true)
|
||||
{
|
||||
release_project_append_os_lock_then_repair_acl(file, path, &error)?;
|
||||
return Ok(None);
|
||||
}
|
||||
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
@@ -3088,3 +3088,231 @@ fn agent_db_tail_repair_handles_an_exact_limit_record() {
|
||||
fs::remove_dir_all(valid_root).ok();
|
||||
fs::remove_dir_all(invalid_root).ok();
|
||||
}
|
||||
|
||||
/// append 锁的等待预算必须与项目写锁的完整窗口同口径。
|
||||
///
|
||||
/// 它曾经是 10ms × 100 ≈ 1s,而持锁方做的可能是"整份读一份 7MB 历史"甚至等 UAC:
|
||||
/// 那个窗口必然打满,打满的后果是用户直接看到"写历史失败"。这条用例把口径钉在常量上,
|
||||
/// 谁把它改回 1s 都会红。
|
||||
#[test]
|
||||
fn append_lock_wait_budget_matches_the_project_write_lock_window() {
|
||||
assert_eq!(
|
||||
PROJECT_APPEND_LOCK_RETRY_INTERVAL,
|
||||
std::time::Duration::from_millis(5)
|
||||
);
|
||||
assert_eq!(PROJECT_APPEND_LOCK_WAIT_ATTEMPTS, 2_000);
|
||||
assert_eq!(PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS, 200);
|
||||
assert!(
|
||||
PROJECT_APPEND_LOCK_RETRY_INTERVAL * 2_000 >= std::time::Duration::from_secs(10),
|
||||
"append 锁完整窗口必须 >= 项目写锁的 10s"
|
||||
);
|
||||
assert!(
|
||||
PROJECT_APPEND_LOCK_RETRY_INTERVAL * 200 <= std::time::Duration::from_secs(1),
|
||||
"只读短窗口必须留在 1s 级,避免把面板读路径一起拖住"
|
||||
);
|
||||
}
|
||||
|
||||
fn append_lock_test_target(test_name: &str) -> (PathBuf, PathBuf) {
|
||||
let root = unique_agent_db_test_root(test_name);
|
||||
let target = root.join(".agent/conversations/project.jsonl");
|
||||
fs::create_dir_all(target.parent().expect("history parent")).expect("history dir");
|
||||
(root, target)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn hold_lock_file_exclusively_for_test(path: &Path) -> File {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.share_mode(0)
|
||||
.open(path)
|
||||
.expect("hold lock file exclusively")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn hold_lock_file_exclusively_for_test(path: &Path) -> File {
|
||||
use std::os::fd::AsRawFd;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.expect("open lock file");
|
||||
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
assert_eq!(result, 0, "flock lock file");
|
||||
file
|
||||
}
|
||||
|
||||
/// 锁文件必须留下"谁在持锁"的诊断元数据:零字节锁文件让现场无法回答是谁在持锁,
|
||||
/// 而"可操作提示"正是靠它才能成立。这里也钉住"只诊断、不抢占"——元数据只被读来报错。
|
||||
#[test]
|
||||
fn append_lock_file_records_the_holder_for_postmortem() {
|
||||
let (root, target) = append_lock_test_target("append-lock-diagnostic");
|
||||
let lock = project_append_lock_for(&target).expect("resolve lock");
|
||||
drop(
|
||||
lock.lock("DirectProject 历史追加写")
|
||||
.expect("acquire append lock"),
|
||||
);
|
||||
|
||||
let lock_path = project_append_os_lock_path(&target).expect("lock path");
|
||||
let raw = fs::read_to_string(&lock_path).expect("read lock file");
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("lock file carries diagnostic json");
|
||||
assert_eq!(
|
||||
record.get("pid").and_then(serde_json::Value::as_u64),
|
||||
Some(u64::from(std::process::id())),
|
||||
"{raw}"
|
||||
);
|
||||
assert_eq!(
|
||||
record.get("label").and_then(serde_json::Value::as_str),
|
||||
Some("DirectProject 历史追加写"),
|
||||
"{raw}"
|
||||
);
|
||||
assert!(record.get("processStartedAt").is_some(), "{raw}");
|
||||
assert!(record.get("acquiredAt").is_some(), "{raw}");
|
||||
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_lock_holder_diagnostic_reports_the_recorded_pid() {
|
||||
let (root, target) = append_lock_test_target("append-lock-holder-diagnostic");
|
||||
let lock_path = project_append_os_lock_path(&target).expect("lock path");
|
||||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||||
fs::write(
|
||||
&lock_path,
|
||||
"{\"acquiredAt\":1,\"label\":\"测试用途\",\"pid\":4242,\"processStartedAt\":7}",
|
||||
)
|
||||
.expect("seed lock file");
|
||||
|
||||
let diagnostic = project_append_lock_holder_diagnostic(&lock_path);
|
||||
assert!(diagnostic.contains("pid=4242"), "{diagnostic}");
|
||||
assert!(diagnostic.contains('7'), "{diagnostic}");
|
||||
assert!(diagnostic.contains("测试用途"), "{diagnostic}");
|
||||
|
||||
fs::remove_file(&lock_path).ok();
|
||||
let missing = project_append_lock_holder_diagnostic(&lock_path);
|
||||
assert!(missing.contains("身份不可读"), "{missing}");
|
||||
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 取锁必须同时写出与锁同级的旁路诊断文件。
|
||||
///
|
||||
/// Windows 上持锁方以 zero-share 打开锁文件本身,别的进程连读都读不到,"谁在持锁"就只能靠
|
||||
/// 这个普通共享的旁路文件回答——那正是这套 UAC / 锁争用改动要落地的平台。
|
||||
#[test]
|
||||
fn append_lock_acquisition_writes_the_holder_record_sidecar() {
|
||||
let (root, target) = append_lock_test_target("append-lock-holder-sidecar");
|
||||
let lock = project_append_lock_for(&target).expect("resolve lock");
|
||||
drop(
|
||||
lock.lock("DirectProject 历史追加写")
|
||||
.expect("acquire append lock"),
|
||||
);
|
||||
|
||||
let lock_path = project_append_os_lock_path(&target).expect("lock path");
|
||||
let sidecar = project_append_lock_holder_record_path(&lock_path);
|
||||
let raw = fs::read_to_string(&sidecar).expect("read holder record sidecar");
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("parse holder record sidecar");
|
||||
assert_eq!(
|
||||
record["pid"],
|
||||
serde_json::json!(u64::from(std::process::id()))
|
||||
);
|
||||
assert_eq!(
|
||||
record["label"],
|
||||
serde_json::json!("DirectProject 历史追加写")
|
||||
);
|
||||
assert!(record["acquiredAt"].is_number(), "{raw}");
|
||||
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 锁文件读不到时,诊断必须退回旁路文件,而不是直接判"身份不可读"。
|
||||
#[test]
|
||||
fn append_lock_holder_diagnostic_falls_back_to_the_sidecar() {
|
||||
let (root, target) = append_lock_test_target("append-lock-holder-sidecar-fallback");
|
||||
let lock_path = project_append_os_lock_path(&target).expect("lock path");
|
||||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||||
fs::write(
|
||||
project_append_lock_holder_record_path(&lock_path),
|
||||
"{\"acquiredAt\":1,\"label\":\"旁路用途\",\"pid\":4242,\"processStartedAt\":7}",
|
||||
)
|
||||
.expect("seed holder record sidecar");
|
||||
// 锁文件此刻不存在:旧实现只会报"身份不可读"。
|
||||
assert!(!lock_path.exists());
|
||||
|
||||
let diagnostic = project_append_lock_holder_diagnostic(&lock_path);
|
||||
assert!(diagnostic.contains("pid=4242"), "{diagnostic}");
|
||||
assert!(diagnostic.contains("旁路用途"), "{diagnostic}");
|
||||
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 同进程内的读路径不能被写者的进程内锁拖住:短窗口必须有界地失败,而不是等写者走完
|
||||
/// 完整的跨进程等待窗口。
|
||||
#[test]
|
||||
fn append_lock_short_wait_is_bounded_while_a_same_process_writer_holds_the_lock() {
|
||||
let (root, target) = append_lock_test_target("append-lock-process-lock-bound");
|
||||
let lock = project_append_lock_for(&target).expect("resolve lock");
|
||||
let writer = lock
|
||||
.lock("DirectProject 历史追加写")
|
||||
.expect("acquire append lock");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let error = match lock.lock_short("DirectProject 历史追加读") {
|
||||
Ok(_guard) => panic!("进程内锁被占满时短窗口必须失败而不是无限等待"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
|
||||
"{error}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(5),
|
||||
"短窗口必须留在 1s 级,实际等待 {elapsed:?}"
|
||||
);
|
||||
|
||||
drop(writer);
|
||||
drop(
|
||||
lock.lock_short("DirectProject 历史追加读")
|
||||
.expect("reacquire after writer releases"),
|
||||
);
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**——
|
||||
/// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。
|
||||
#[test]
|
||||
fn append_lock_short_wait_times_out_with_the_holder_and_leaks_nothing() {
|
||||
let (root, target) = append_lock_test_target("append-lock-timeout");
|
||||
let lock = project_append_lock_for(&target).expect("resolve lock");
|
||||
// 先让生产代码创建并收紧锁文件(本地 harden 只在首次创建时发生)。
|
||||
drop(
|
||||
lock.lock_short("DirectProject 历史追加写")
|
||||
.expect("first acquire"),
|
||||
);
|
||||
let lock_path = project_append_os_lock_path(&target).expect("lock path");
|
||||
let held = hold_lock_file_exclusively_for_test(&lock_path);
|
||||
|
||||
let error = match lock.lock_short("DirectProject 历史追加写") {
|
||||
Ok(_guard) => panic!("exclusively held lock must not be acquired"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
|
||||
"{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("持锁方"),
|
||||
"超时文案必须回答是谁在持锁:{error}"
|
||||
);
|
||||
|
||||
drop(held);
|
||||
drop(
|
||||
lock.lock_short("DirectProject 历史追加写")
|
||||
.expect("reacquire after release"),
|
||||
);
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user