Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd94db73c9 | |||
| e50d678411 | |||
| 727794bc95 | |||
| cc9b95ddef | |||
| fafe6b63cd | |||
| 11c8cbdf67 | |||
| ff42fff61c | |||
| e8c5d2e247 | |||
| ab86efa421 | |||
| a12d18c1cd | |||
| 71e9ad3133 | |||
| 06f738dc99 | |||
| 8c639e5d13 | |||
| 59cab3d9fd | |||
| 89447ed432 | |||
| 3ebfcc0c2f | |||
| 9b5d1fe107 | |||
| 4ecab19429 | |||
| 81c2389132 |
@@ -74,6 +74,14 @@ codegen-units = 256
|
||||
lto = "off"
|
||||
incremental = true
|
||||
|
||||
# Runner 启动阶段会对当前 Debug 可执行文件计算 SHA-256。仅优化密码学依赖,
|
||||
# 保持业务代码的 Debug 编译速度,同时避免整份 Debug 构建因未优化 hash 热点而阻塞启动。
|
||||
[profile.dev.package.sha2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.digest]
|
||||
opt-level = 3
|
||||
|
||||
[profile.test]
|
||||
opt-level = 0
|
||||
debug = 1
|
||||
|
||||
@@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
|
||||
if normalized.contains("permission-denied") || normalized.contains("http 403") {
|
||||
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
|
||||
}
|
||||
if error.contains("项目正在被其他写操作占用") {
|
||||
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
|
||||
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
|
||||
}
|
||||
if error.contains("身份不唯一")
|
||||
|
||||
@@ -1473,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
return Err("工具参数 content 不能包含 NUL".to_string());
|
||||
}
|
||||
reject_command_output_wrapper(content)?;
|
||||
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
|
||||
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
|
||||
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
|
||||
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
|
||||
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"direct-codex.file.write",
|
||||
)?;
|
||||
let written = write_local_project_file_at(root, &path, content)?;
|
||||
let revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||
Ok::<_, String>(json!({
|
||||
@@ -1493,6 +1500,27 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async,
|
||||
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
|
||||
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
|
||||
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
|
||||
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
|
||||
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
|
||||
let task_root = root.clone();
|
||||
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => bridge_tool_result(
|
||||
redact_agent_runtime_error(
|
||||
&root,
|
||||
&format!("agc_write_file 阻塞任务未返回:{error}"),
|
||||
480,
|
||||
),
|
||||
Vec::new(),
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
|
||||
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
|
||||
if asset_id.trim().is_empty() {
|
||||
@@ -2307,7 +2335,9 @@ async fn handle_direct_tool_bridge(
|
||||
bridge_list_registered_assets(&state.root, &request.arguments)
|
||||
}
|
||||
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
|
||||
"agc_write_file" => bridge_write_file(&state.root, &request.arguments),
|
||||
"agc_write_file" => {
|
||||
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
|
||||
}
|
||||
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
|
||||
"agc_import_account_assets" => {
|
||||
bridge_import_account_assets(&state, &request.arguments).await
|
||||
@@ -2712,6 +2742,185 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
|
||||
/// 而不是互相报"项目正在被其他写操作占用"。
|
||||
#[test]
|
||||
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
|
||||
let temporary = tempfile::tempdir().expect("create parallel direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试")
|
||||
.expect("initialize parallel direct write root");
|
||||
let root = temporary.path().to_path_buf();
|
||||
let paths = (0..4)
|
||||
.map(|index| format!("game/parallel-{index}.js"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let results = std::thread::scope(|scope| {
|
||||
let handles = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let root = root.clone();
|
||||
let path = path.clone();
|
||||
scope.spawn(move || {
|
||||
let result = bridge_write_file(
|
||||
&root,
|
||||
&json!({ "path": path, "content": format!("// {path}\n") }),
|
||||
);
|
||||
(path, result)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("parallel direct write must not panic"))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
for (path, result) in &results {
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"parallel direct write of {path} must succeed: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(path)).expect("read parallel direct write"),
|
||||
format!("// {path}\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
|
||||
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
|
||||
#[test]
|
||||
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
|
||||
let temporary = tempfile::tempdir().expect("create contended direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试")
|
||||
.expect("initialize contended direct write root");
|
||||
let root = temporary.path().to_path_buf();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let holder_barrier = std::sync::Arc::clone(&barrier);
|
||||
let holder_root = root.clone();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||
.expect("acquire a short-lived project writer");
|
||||
holder_barrier.wait();
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
drop(lock);
|
||||
});
|
||||
barrier.wait();
|
||||
|
||||
let result = bridge_write_file(
|
||||
&root,
|
||||
&json!({ "path": "game/waited.js", "content": "// waited\n" }),
|
||||
);
|
||||
|
||||
holder.join().expect("join the short-lived project writer");
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"the direct write must wait out a short same-process writer: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"),
|
||||
"// waited\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池,
|
||||
/// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步
|
||||
/// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。
|
||||
#[tokio::test]
|
||||
async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create blocking pool write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试")
|
||||
.expect("initialize blocking pool write root");
|
||||
let state = direct_tool_bridge_state(temporary.path().to_path_buf());
|
||||
|
||||
// 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。
|
||||
let holder_root = temporary.path().to_path_buf();
|
||||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||||
.expect("acquire the concurrent project writer");
|
||||
let holder = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
drop(lock);
|
||||
});
|
||||
|
||||
// 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。
|
||||
let heartbeat = Arc::new(AtomicBool::new(false));
|
||||
let heartbeat_writer = Arc::clone(&heartbeat);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
heartbeat_writer.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let response = handle_direct_tool_bridge(
|
||||
axum::extract::State(state),
|
||||
axum::Json(DirectToolBridgeRequest {
|
||||
tool: "agc_write_file".to_string(),
|
||||
arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.0;
|
||||
|
||||
holder.join().expect("join the concurrent project writer");
|
||||
assert!(
|
||||
heartbeat.load(Ordering::SeqCst),
|
||||
"写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker"
|
||||
);
|
||||
assert_eq!(
|
||||
response.get("isError").and_then(Value::as_bool),
|
||||
Some(false),
|
||||
"the pooled direct write must still wait out the holder: {response}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(temporary.path().join("game/blocking-pool.js"))
|
||||
.expect("read pooled direct write"),
|
||||
"// pooled\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create acl direct write root");
|
||||
init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试")
|
||||
.expect("initialize acl direct write root");
|
||||
let agent_directory = temporary.path().join(".agent");
|
||||
let original = fs::metadata(&agent_directory)
|
||||
.expect("read control directory metadata")
|
||||
.permissions();
|
||||
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
|
||||
.expect("drop write permission on the control directory");
|
||||
|
||||
let result = bridge_write_file(
|
||||
temporary.path(),
|
||||
&json!({ "path": "game/acl-denied.js", "content": "// denied\n" }),
|
||||
);
|
||||
fs::set_permissions(&agent_directory, original)
|
||||
.expect("restore control directory permission");
|
||||
|
||||
if result.get("isError").and_then(Value::as_bool) != Some(true) {
|
||||
// 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。
|
||||
return;
|
||||
}
|
||||
let text = result
|
||||
.pointer("/content/0/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!text.contains("项目正在被其他写操作占用"),
|
||||
"a permission denial must not be projected as lock contention: {text}"
|
||||
);
|
||||
assert!(!temporary.path().join("game/acl-denied.js").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
|
||||
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
|
||||
|
||||
@@ -1822,27 +1822,45 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
|
||||
/// Take the project write lock, riding out transient contention for at most
|
||||
/// `max_attempts` polls.
|
||||
///
|
||||
/// `项目正在被其他写操作占用:` is the one lock error that means
|
||||
/// "nothing is broken, the current holder is mid-write" — every other variant
|
||||
/// (a torn lock file, a denied path) is returned immediately. Callers pick the
|
||||
/// budget from what a lost race costs them: a one-shot user intent waits out the
|
||||
/// full window, a poll that will run again shortly waits far less.
|
||||
/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的
|
||||
/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是
|
||||
/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上——
|
||||
/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race
|
||||
/// costs them: a one-shot user intent waits out the full window, a poll that will run
|
||||
/// again shortly waits far less.
|
||||
fn acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||
root: &Path,
|
||||
command_id: &str,
|
||||
max_attempts: usize,
|
||||
) -> Result<ProjectWriteLock, String> {
|
||||
let max_attempts = max_attempts.max(1);
|
||||
let started_at = std::time::Instant::now();
|
||||
for attempt in 0..max_attempts {
|
||||
match acquire_project_write_lock(root, command_id) {
|
||||
Err(error)
|
||||
if error.starts_with("项目正在被其他写操作占用:")
|
||||
&& attempt + 1 < max_attempts =>
|
||||
{
|
||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||
let failure = match acquire_project_write_lock_failure(root, command_id) {
|
||||
Ok(lock) => return Ok(lock),
|
||||
Err(failure) if failure.is_retryable() => failure,
|
||||
Err(failure) => return Err(failure.message()),
|
||||
};
|
||||
if attempt + 1 == max_attempts {
|
||||
// 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。
|
||||
// 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人",
|
||||
// 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。
|
||||
// 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待:
|
||||
// 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate
|
||||
// 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。
|
||||
let waited = max_attempts > 1;
|
||||
let (projection, message) = failure.exhausted_projection(waited);
|
||||
if waited {
|
||||
app_log!(
|
||||
"project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}",
|
||||
attempt + 1,
|
||||
started_at.elapsed().as_millis(),
|
||||
crate::project::project_write_lock_contention_diagnostic(root)
|
||||
);
|
||||
}
|
||||
result => return result,
|
||||
return Err(message);
|
||||
}
|
||||
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
|
||||
}
|
||||
unreachable!("project write lock retry loop always returns")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome {
|
||||
|
||||
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
error.starts_with("项目正在被其他写操作占用:")
|
||||
error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
|
||||
|| error.contains("另一个程序正在使用此文件")
|
||||
|| normalized.contains("sharing violation")
|
||||
|| normalized.contains("lock violation")
|
||||
|
||||
+1
-1
@@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2(
|
||||
"planning.v2.hydrate",
|
||||
) {
|
||||
Ok(lock) => lock,
|
||||
Err(error) if error.starts_with("项目正在被其他写操作占用:") => {
|
||||
Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => {
|
||||
return Ok(None)
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
|
||||
@@ -3640,7 +3640,38 @@ pub(crate) fn merge_game_creator_config_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
|
||||
/// Only files inside the managed runtime config directory (real credentials)
|
||||
/// may use the private-read channel: it hardens the owner/DACL on every read.
|
||||
/// Repository-adjacent fallback templates and overlay files are shared inputs
|
||||
/// that can be git-tracked; privatizing one on read silently locks the
|
||||
/// worktree template to whichever account happened to run the dev CLI, so
|
||||
/// they must go through the non-mutating snapshot channel instead.
|
||||
pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool {
|
||||
game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory))
|
||||
}
|
||||
|
||||
fn read_game_creator_snapshot_file_to_string(
|
||||
path: &Path,
|
||||
label: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<String, String> {
|
||||
let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?;
|
||||
if metadata.len() > max_bytes {
|
||||
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
|
||||
}
|
||||
let mut content = String::with_capacity(metadata.len() as usize);
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
|
||||
let final_metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
|
||||
if final_metadata.len() != metadata.len() {
|
||||
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub(crate) fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
|
||||
let backup_path = game_creator_config_backup_path(path);
|
||||
let path_exists = validate_game_creator_config_file_entry(path)?;
|
||||
let read_path = if path_exists {
|
||||
@@ -3650,7 +3681,11 @@ fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String>
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?;
|
||||
let content = if game_creator_config_path_is_runtime_managed(read_path) {
|
||||
read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?
|
||||
} else {
|
||||
read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)?
|
||||
};
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ mod resource_dependency_graph;
|
||||
mod resource_editor;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
mod write_lock;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_canvas::*;
|
||||
@@ -30,3 +31,4 @@ pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_editor::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
pub(crate) use write_lock::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
|
||||
const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024;
|
||||
const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt";
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::*;
|
||||
use super::filesystem::validate_portable_project_path_component;
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1184,6 +1184,56 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir() {
|
||||
let root = unique_project_path();
|
||||
let template_dir = root.join("apps").join("ai-game-creator-shell");
|
||||
fs::create_dir_all(&template_dir).expect("fallback template dir");
|
||||
let template = template_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||||
fs::write(
|
||||
&template,
|
||||
"{\n \"llm\": { \"model\": \"fallback-template-model\" }\n}\n",
|
||||
)
|
||||
.expect("write fallback template");
|
||||
// 设置一个与模板无关的 runtime dir,让分类断言真正覆盖"按路径归属"而非
|
||||
// "runtime dir 为 None 时恒 false"的全局开关。
|
||||
let runtime_root = unique_project_path();
|
||||
fs::create_dir_all(&runtime_root).expect("unrelated runtime config dir");
|
||||
let _guard = use_test_runtime_config_dir(runtime_root.clone());
|
||||
|
||||
// 仓库旁边的回退模板是共享输入,读取绝不能走会收紧 owner/DACL 的私有通道。
|
||||
assert!(!game_creator_config_path_is_runtime_managed(&template));
|
||||
assert!(game_creator_config_path_is_runtime_managed(
|
||||
&runtime_root.join(GAME_CREATOR_CONFIG_FILE_NAME)
|
||||
));
|
||||
let content = read_game_creator_config_file(&template).expect("read fallback template");
|
||||
assert!(content
|
||||
.expect("fallback template content")
|
||||
.contains("fallback-template-model"));
|
||||
fs::remove_dir_all(root).ok();
|
||||
fs::remove_dir_all(runtime_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_read_stays_on_private_channel_inside_runtime_dir() {
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(&root).expect("runtime config dir");
|
||||
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||||
fs::write(
|
||||
&config_path,
|
||||
"{\n \"llm\": { \"model\": \"managed-config-model\" }\n}\n",
|
||||
)
|
||||
.expect("write runtime config");
|
||||
let _guard = use_test_runtime_config_dir(root.clone());
|
||||
|
||||
assert!(game_creator_config_path_is_runtime_managed(&config_path));
|
||||
let content = read_game_creator_config_file(&config_path).expect("read runtime config");
|
||||
assert!(content
|
||||
.expect("runtime config content")
|
||||
.contains("managed-config-model"));
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_config_check_reports_agent_specific_config_paths() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -356,3 +356,89 @@ fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() {
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// Issue #318 取证缺口:争用错误必须点名持锁方,现场才能回答"谁在持锁、是不是自己人"。
|
||||
/// 另一个活进程持锁时必须报成争用、带出身份,并且不能把它的锁当成残留回收掉。
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn project_write_lock_contention_names_a_live_external_holder() {
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(root.join(".agent")).expect("创建 .agent 目录");
|
||||
let holder = spawn_unrelated_live_process();
|
||||
let holder_pid = holder.id();
|
||||
write_project_lock_fixture(
|
||||
&root,
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"commandId": "external-editor-writer",
|
||||
"pid": holder_pid,
|
||||
"createdAt": unix_timestamp(),
|
||||
"nonce": 7,
|
||||
}))
|
||||
.expect("序列化外部持有者锁 fixture"),
|
||||
);
|
||||
let held_content = fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("读回持有者锁");
|
||||
|
||||
let error =
|
||||
acquire_project_write_lock(&root, "file.write").expect_err("活的外部进程持锁时必须报争用");
|
||||
assert!(
|
||||
error.starts_with("项目正在被其他写操作占用:"),
|
||||
"争用必须保持共享前缀:{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("commandId=external-editor-writer"),
|
||||
"错误必须点名持锁命令:{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains(&format!("pid={holder_pid}")),
|
||||
"错误必须点名持锁进程:{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("ownerIsSelf=false"),
|
||||
"错误必须说明持锁方不是本进程:{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("复查持有者锁"),
|
||||
held_content,
|
||||
"活外部持有者的锁文件不得被回收或改写"
|
||||
);
|
||||
assert!(
|
||||
project_write_lock_contention_diagnostic(&root).contains(&format!("pid={holder_pid}")),
|
||||
"等待日志必须带同一份持锁方身份"
|
||||
);
|
||||
|
||||
stop_unrelated_live_process(holder);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// Issue #318 第 3 条:权限拒绝不能再投影成"被其他写操作占用"。
|
||||
/// 文案刻意不含争用前缀,有界等待和前端才不会把它当成"等一下就好"的瞬时状态。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_write_lock_does_not_project_permission_denial_as_contention() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = unique_project_path();
|
||||
let agent_directory = root.join(".agent");
|
||||
fs::create_dir_all(&agent_directory).expect("创建 .agent 目录");
|
||||
let original = fs::metadata(&agent_directory)
|
||||
.expect("读取控制目录元数据")
|
||||
.permissions();
|
||||
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
|
||||
.expect("去掉控制目录写权限");
|
||||
|
||||
let outcome = acquire_project_write_lock(&root, "file.write");
|
||||
fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限");
|
||||
|
||||
// CI 容器以 root 运行,0o500 目录照样可以创建文件;此时本用例的前提不成立,
|
||||
// 直接跳过。分类判据本身另有不依赖 ACL 环境的纯函数用例覆盖。
|
||||
let Err(error) = outcome else {
|
||||
return;
|
||||
};
|
||||
|
||||
assert!(
|
||||
!error.starts_with("项目正在被其他写操作占用:"),
|
||||
"权限拒绝不得投影成写锁争用:{error}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -251,6 +251,10 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo
|
||||
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
|
||||
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
|
||||
import { captureAgentRuntimeError } from './services/errorReporting';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
requestPlatformSessionRefresh,
|
||||
} from './services/platformSession';
|
||||
import type { HomeCreationType } from './view/home';
|
||||
import {
|
||||
type ProjectAgentResultSummary,
|
||||
@@ -264,6 +268,35 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
|
||||
const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX =
|
||||
'direct-codex-turn-already-running:';
|
||||
|
||||
function isDirectCodexAuthenticationRequired(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('authentication-required') ||
|
||||
message.includes('codex-app-server-error:unauthorized') ||
|
||||
/kind=codex-app-server-unauthorized(?=\s|$)/.test(message) ||
|
||||
message.includes('登录已失效')
|
||||
);
|
||||
}
|
||||
|
||||
async function withDirectCodexSessionRefresh<T>(operation: () => Promise<T>) {
|
||||
const generation = currentPlatformSessionGeneration();
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!isDirectCodexAuthenticationRequired(error)) throw error;
|
||||
if (currentPlatformSessionGeneration() !== generation) throw error;
|
||||
const refresh = await requestPlatformSessionRefresh();
|
||||
if (refresh.status === 'failed') throw error;
|
||||
if (
|
||||
refresh.status !== 'refreshed' ||
|
||||
currentPlatformSessionGeneration() !== refresh.generation
|
||||
) {
|
||||
throw new Error('登录账号已变化,原对话请求已停止');
|
||||
}
|
||||
return operation();
|
||||
}
|
||||
}
|
||||
|
||||
const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([
|
||||
'accepted',
|
||||
'running',
|
||||
@@ -5933,10 +5966,20 @@ export function App({
|
||||
if (attachments?.length) {
|
||||
directTurnInput.attachments = attachments;
|
||||
}
|
||||
const reply = await directInvoke<string>(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
directTurnInput,
|
||||
);
|
||||
const reply = await withDirectCodexSessionRefresh(() => {
|
||||
// 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath: directProjectPath,
|
||||
turnId: clientTurnId,
|
||||
lastSequence: -1,
|
||||
receivedDirectUpdate: false,
|
||||
};
|
||||
setDirectCodexStatus('accepted');
|
||||
return directInvoke<string>(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
directTurnInput,
|
||||
);
|
||||
});
|
||||
// Rust already persisted the complete raw response items. Invalidate
|
||||
// any history snapshot captured before the turn completed.
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
} from '../../../../packages/shared/src';
|
||||
import { fetchClientHttp } from './clientHttp';
|
||||
import { captureClientError } from './errorReporting';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
requestPlatformSessionRefresh,
|
||||
} from './platformSession';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
@@ -84,23 +88,40 @@ export async function requestClientApi<T>(
|
||||
fallbackMessage: string,
|
||||
options: { skipAuth?: boolean } = {},
|
||||
) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
|
||||
if (!options.skipAuth) {
|
||||
const token = getStoredAuthAccessToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
const generation = currentPlatformSessionGeneration();
|
||||
const request = async () => {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
|
||||
if (!options.skipAuth) {
|
||||
const token = getStoredAuthAccessToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fetchClientHttp(url, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
throw apiNetworkError(url, error);
|
||||
}
|
||||
};
|
||||
|
||||
let response = await request();
|
||||
// Access tokens are short lived. Refresh the cookie-backed session once and
|
||||
// retry the original request so callers do not need to handle token expiry.
|
||||
if (!options.skipAuth && response.status === 401) {
|
||||
if (currentPlatformSessionGeneration() === generation) {
|
||||
const refresh = await requestPlatformSessionRefresh();
|
||||
if (
|
||||
refresh.status === 'refreshed' &&
|
||||
currentPlatformSessionGeneration() === refresh.generation
|
||||
) {
|
||||
response = await request();
|
||||
}
|
||||
}
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchClientHttp(url, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
throw apiNetworkError(url, error);
|
||||
}
|
||||
if (!response.ok) {
|
||||
captureApiErrorStatus(url, response);
|
||||
|
||||
@@ -5983,6 +5983,13 @@ iframe.preview-frame {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
/* CanvasWorld 使用 CANVAS_RENDER_SUPERSAMPLE 超采样;这些 Lucide 卡片图标需保持屏幕级描边宽度。 */
|
||||
.game-resource-card-placeholder > svg *,
|
||||
.game-resource-card-version-visual > svg *,
|
||||
.game-resource-card-audio-visual > svg * {
|
||||
stroke-width: calc(2px * var(--genarrative-image-canvas-inverse-scale, 1));
|
||||
}
|
||||
|
||||
.game-resource-card-open {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@@ -1989,6 +1989,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(styles).toMatch(
|
||||
/\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-resource-card-placeholder\s*>\s*svg\s*\*,\s*\.game-resource-card-version-visual\s*>\s*svg\s*\*,\s*\.game-resource-card-audio-visual\s*>\s*svg\s*\*\s*\{[^}]*stroke-width:\s*calc\(2px\s*\*\s*var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
requestClientApi,
|
||||
setStoredAuthAccessToken,
|
||||
} from '../src/services/clientApi';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
commitAuthenticatedPlatformSession,
|
||||
currentPlatformSessionGeneration,
|
||||
resetPlatformSessionStateForTests,
|
||||
} from '../src/services/platformSession';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
|
||||
vi.mock('../src/services/errorReporting', () => ({
|
||||
captureClientError: vi.fn(),
|
||||
}));
|
||||
|
||||
const user = { id: 'session-user' } as AuthUser;
|
||||
const nativeInvoke = vi.fn(async () => null);
|
||||
const catalog = { models: [{ id: 'quality', displayName: '高质量' }] };
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), { status });
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPlatformSessionStateForTests();
|
||||
window.localStorage.clear();
|
||||
nativeInvoke.mockClear();
|
||||
window.__TAURI__ = { core: { invoke: nativeInvoke } };
|
||||
setStoredAuthAccessToken('expired-token');
|
||||
await commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
currentPlatformSessionGeneration(),
|
||||
);
|
||||
nativeInvoke.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPlatformSessionStateForTests();
|
||||
window.localStorage.clear();
|
||||
delete window.__TAURI__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
|
||||
let refreshCalls = 0;
|
||||
let modelCalls = 0;
|
||||
const fetch = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockImplementation(async (input, init) => {
|
||||
if (input === '/api/auth/refresh') {
|
||||
refreshCalls += 1;
|
||||
return json({ token: 'fresh-token' });
|
||||
}
|
||||
if (input === '/api/auth/me') return json({ user });
|
||||
modelCalls += 1;
|
||||
const token = new Headers(init?.headers).get('Authorization');
|
||||
if (token === 'Bearer expired-token') return json({}, 401);
|
||||
expect(token).toBe('Bearer fresh-token');
|
||||
expect(nativeInvoke).toHaveBeenCalledWith(
|
||||
'install_platform_account_session',
|
||||
expect.objectContaining({
|
||||
accessToken: 'fresh-token',
|
||||
userId: user.id,
|
||||
}),
|
||||
);
|
||||
return json(catalog);
|
||||
});
|
||||
|
||||
const results = await Promise.all([
|
||||
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
||||
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
||||
]);
|
||||
expect(results).toEqual([catalog, catalog]);
|
||||
expect(refreshCalls).toBe(1);
|
||||
expect(modelCalls).toBe(4);
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => {
|
||||
const fetch = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(json({}, status))
|
||||
.mockResolvedValueOnce(json({}, 401));
|
||||
await expect(
|
||||
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
|
||||
).rejects.toMatchObject({ status });
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('跳过鉴权的请求不触发续期', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401));
|
||||
await expect(
|
||||
requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('403 权限拒绝不触发续期或重发写请求', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403));
|
||||
await expect(
|
||||
requestClientApi(
|
||||
'/api/example',
|
||||
{ method: 'POST', body: '{}' },
|
||||
'权限不足',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('续期成功后的再次未授权不循环重试', async () => {
|
||||
const fetch = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(json({}, 401))
|
||||
.mockResolvedValueOnce(json({ token: 'fresh-token' }))
|
||||
.mockResolvedValueOnce(json({ user }))
|
||||
.mockResolvedValueOnce(json({}, 401));
|
||||
await expect(
|
||||
requestClientApi('/api/llm/models', {}, '读取失败'),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
expect(fetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => {
|
||||
let finish!: (response: Response) => void;
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const pending = requestClientApi('/api/llm/models', {}, '读取失败');
|
||||
const rejection = expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
const generation = beginPlatformSessionTransition();
|
||||
setStoredAuthAccessToken('other-token');
|
||||
await commitAuthenticatedPlatformSession(
|
||||
{ ...user, id: 'other-user' },
|
||||
generation,
|
||||
);
|
||||
finish(json({}, 401));
|
||||
await rejection;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。
|
||||
- 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。
|
||||
- 影响范围:`config.rs` 的新建 harden 与提权命令行、`filesystem.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。
|
||||
- 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。
|
||||
- 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
||||
|
||||
@@ -8055,7 +8055,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移
|
||||
|
||||
- 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一。
|
||||
- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`。
|
||||
- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算;超采样比例统一由共享包的 `CANVAS_RENDER_SUPERSAMPLE` 常量定义。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。资源依赖关系线继续使用几何 overlay 的非缩放描边;资源卡片中的 Lucide 等静态 SVG 图标使用局部 inverse-scale `stroke-width` 补偿,保持屏幕级线宽,不把该规则扩展为全局 SVG 规则;位图维持浏览器默认插值,不全局启用 `pixelated`。
|
||||
- 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。
|
||||
- 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding` 和 `git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸。
|
||||
|
||||
@@ -8197,3 +8197,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。
|
||||
- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。
|
||||
- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。
|
||||
|
||||
## 2026-09-10 Direct 写通道纳入统一项目锁等待窗口并补齐持锁方可诊断
|
||||
|
||||
- 背景:Issue #318。`agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,却用零等待取锁,任何重叠都在 24-42ms 内被投影成“项目正在被其他写操作占用”;同一形状已在 2026-07-22 由 `file.write / file.patch / file.delete` 用有界等待修过,本项目技术方案的 2026-08-13 一节也已规定这类争用结果“统一投影为争用并进入既有有界等待”。现场取证还缺 `commandId / pid / createdAt / ownerIsSelf`,无法回答“谁在持锁”,加上 `create_new` 把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,排障被引向“残留锁”。
|
||||
- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。①′ 这条等待是同步轮询(最多约 10 秒),handler 是 async,因此写路径经 `spawn_blocking` 走阻塞线程池:直接在 handler 里同步等待会占住 tokio worker,争用窗口内并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令,破坏 Issue #318 现场“只读工具全部正常”的诊断特征。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成可重试(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类,**重试性只看错误码**:Windows 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生,hydrate 的单次试探既不写日志也不改判。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。⑤ 平台判据的每一环都要带平台:终态改判也曾只比 `ErrorKind`,而 errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`,CI 直接把它判成 `contention`;现由 `Retryable { platform, path, source }` 携带平台。
|
||||
- 为什么不按“目标是否存在”当场分类:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)` 而 `exists()` 已经报 false(本机 6 万次建锁 / 删锁竞争实测 396-538 例命中该组合)。按一次 metadata 观察判成权限拒绝,等待层会立刻失败关闭,等于把 Issue #318 的“毫秒级直接失败”换成更误导的 ACL 文案;这也是对 2026-08-13 已定口径“这些结果统一投影为争用并进入既有有界等待”的回归。
|
||||
- 复用既有实现:回收判据沿用 2026-09-09 的 `ProjectWriteLockSnapshot` + `project_write_lock_reclaim_decision` + 字节 CAS 删除,不新增第二套回收机制;本次只给快照补 `commandId` 与 `describe_holder()`,供错误文案和日志使用。
|
||||
- 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。
|
||||
- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI(`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。
|
||||
- 关联文档:`docs/project-memory/shared-memory/pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318。
|
||||
|
||||
@@ -4185,6 +4185,14 @@
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs`、`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`。
|
||||
- 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。
|
||||
|
||||
## 仓库回退配置模板不能被读取通道私有化锁定
|
||||
|
||||
- 现象:Windows 上 `apps/ai-game-creator-shell/game-creator.config.json` 莫名其妙被"加锁"(DACL 被剥成只剩一个陌生 SID,连 `Get-Acl` 都 unauthorized),开发 agent 和其他用户无法修改,cargo 也因 `include_str!` 读不到文件而不能编译;手动解锁后过一段时间又被锁。
|
||||
- 原因:无 AppHandle 的开发 CLI(`llm-status`、`agent-run`、`agc:test:chat` 等)经 `game_creator_config_paths()` 从 CWD / `current_exe` 向上回溯 8 级探测到 worktree 里的 git 跟踪模板后,读取走了为 AppData 私密凭据设计的私有通道 `open_project_private_regular_file` → `prepare_game_creator_private_path_for_read`;该函数名为 "for read",在 Windows 上却无条件收紧目标 DACL 为"仅当前进程用户、禁止继承"。沙箱 agent 是其 checkout 文件的 owner,校验通过后被静默私有化,其他账号全部 Access Denied。
|
||||
- 处理:配置读取按路径归属分流——`read_game_creator_config_file` 只对位于 `game_creator_runtime_config_dir()`(AppData 托管目录)内的真实凭据走私有加固读取;仓库旁边的回退模板 / local 覆盖一律走 `open_project_snapshot_regular_file` 非变异快照通道,读取绝不修改 owner / DACL。这与 `open_project_private_regular_file` 注释中"非用户明确选择的文件用 snapshot 读"的既有原则一致。
|
||||
- 教训:任何名为"读前准备"的函数若附带权限收紧副作用,都必须按路径是否属于本进程托管范围设白名单;共享仓库文件、git 跟踪文件永远不在加固范围内。排查"文件莫名被锁"时优先查 DACL owner 是哪位 SID,再倒推哪个进程以该身份运行过。
|
||||
- 验证:`tests::configuration::fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir` 与 `runtime_config_read_stays_on_private_channel_inside_runtime_dir` 锁定两条通道的分流;`node scripts/check-config.mjs` 通过。
|
||||
|
||||
## 项目总控空态和持久 Runtime 不能依赖同一份 Session 索引
|
||||
|
||||
- 现象:新项目尚未发消息时右侧总控区域只剩整块空白;已有 `needs-reconciliation` Runtime 的项目重新打开后,也可能看不到失败状态卡。
|
||||
@@ -5028,6 +5036,11 @@
|
||||
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
|
||||
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
|
||||
|
||||
## AGC 登录态续期必须同步本地运行时
|
||||
|
||||
- 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。
|
||||
- 普通 API 仅在 `401` 时续期并至多重试一次;`403` 权限拒绝不重发。对话续期失败保留原机器可读鉴权错误,避免用户提示退化为普通执行失败;账号代次变化时停止旧请求。
|
||||
|
||||
## AGC 前端等待超时与 worker 端口冲突
|
||||
|
||||
- `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。
|
||||
@@ -5050,7 +5063,7 @@
|
||||
- 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。
|
||||
- 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。
|
||||
- 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。
|
||||
|
||||
## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09)
|
||||
|
||||
@@ -5058,4 +5071,15 @@
|
||||
- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。
|
||||
- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。
|
||||
- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`。
|
||||
|
||||
## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断
|
||||
|
||||
- **现象**:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回 `项目正在被其他写操作占用:<项目根>\.agent\project.lock`;同一轮 15 次写入全部 `status=failed` 且 `durationMs` 只有 24-42ms,而只读工具(`agc_list_project_files`、`agc_list_registered_assets`、`client.session.info`)全部正常,整轮无法写入任何项目文件。
|
||||
- **原因**:`.agent/project.lock` 是 `create_new` 存在性锁,Direct 通道却调零等待的 `acquire_project_write_lock`,与 App 自身其它写通道(美术 lane、revision、conversation、预览等)撞车就直接判死;而 `file.write / file.patch / file.delete` 等入口走的是约 10 秒有界等待。**失败耗时本身就是判据**:几十毫秒说明这个入口根本没等,同等争用在其它通道会被等待窗口吸收。此外错误文案不带 `commandId / pid / createdAt / ownerIsSelf`,又把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,现场很容易被误判成“残留锁”。
|
||||
- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按可重试 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。
|
||||
- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`(`Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。
|
||||
- **补充:同步有界等待不能直接跑在 async handler 里**。这条等待是 2 000 × 5ms 的同步轮询(最多约 10 秒),而 `handle_direct_tool_bridge` 是 async:直接在 handler 里等待会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而 bridge 与只读端点、UI 命令共享同一个 runtime——于是“只读工具全部正常”这条现场诊断特征会在争用窗口内失效,把排障引向错误方向(本次现场正是靠它判断“写锁没释放”的)。做法是把整条写路径挪进 `tokio::task::spawn_blocking`(仓库既有模式),等待语义与错误文案都不变;用例用默认 `current_thread` runtime 加心跳任务锁住这一点:handler 一旦同步阻塞,同一 runtime 上的心跳就完全停摆。**这类“零等待改成有界等待”的改动都要同时问一句:调用方是不是 async,等待窗口会不会占住执行器。**
|
||||
- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。
|
||||
- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、有界等待不占 runtime worker、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。
|
||||
|
||||
@@ -582,7 +582,7 @@ game-project/
|
||||
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
|
||||
- `@genarrative/image-canvas-react` 的 `CanvasWorld` 接收逻辑 `viewport`,`viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
|
||||
- `@genarrative/image-canvas-react` 的 `CanvasWorld` 接收逻辑 `viewport`,`viewport.scale` 表示最终视觉比例;渲染细节由共享包封装,超采样比例统一由共享包的 `CANVAS_RENDER_SUPERSAMPLE` 常量定义。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。资源依赖关系线继续使用几何 overlay 的非缩放描边;Lucide 等静态卡片图标在对应卡片 SVG 子元素上使用局部 inverse-scale `stroke-width` 补偿,保持屏幕级线宽,不扩展为全局 SVG 规则;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
|
||||
|
||||
#### 资源管理串行改造:本体卡、分区缩放与依赖聚类
|
||||
|
||||
@@ -1337,3 +1337,21 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
||||
- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
|
||||
- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
|
||||
- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。
|
||||
|
||||
## 2026-09-10 Direct 写通道项目锁等待、持锁方可诊断与权限分类
|
||||
|
||||
- `agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,原先却用零等待 `acquire_project_write_lock`:任何重叠都在 24-42ms 内被判成“项目正在被其他写操作占用”,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。现统一为 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`:短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误;同一轮并行写多个文件按同一把锁串行。这是 2026-07-22 同一形状修复在 Direct 通道上的补齐,与 2026-08-13 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。
|
||||
- 这条等待是**同步轮询**(2_000 × 5ms,最多约 10 秒),而 `handle_direct_tool_bridge` 是 async handler:直接在 handler 里跑完整条写路径会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime——Issue #318 现场“只读工具全部正常”这条诊断特征会在争用窗口内失效。因此写路径经 `bridge_write_file_in_blocking_pool` 走 `tokio::task::spawn_blocking`(仓库既有模式,如 `codex_app_server.rs` 的 DirectProject 历史落盘),等待语义与错误文案不变;定向用例用默认 `current_thread` runtime 加心跳任务锁住“等待期间 runtime 仍在推进”。
|
||||
- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态;这句话已是 `crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 单一真源,四个站点不再各自手写中文。
|
||||
- `create_new` 的失败必须分三类处置,不能再共用一句文案:可重试(目标已存在、Windows `sharing violation(32)` / `lock violation(33)` / `ACCESS_DENIED(5)`)进入有界等待;明确判定不是争用的权限 / ACL 拒绝(Unix `EACCES`)失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。**重试性只能由错误码决定,不能用 `path.exists()` 这类一次 metadata 观察决定**:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false(本机实测 6 万次建锁 / 删锁竞争里 396-538 例命中该组合)。按“目标不存在”当场判成权限拒绝,等待层就会立刻失败关闭——正是本次要消灭的“毫秒级直接失败”,只是换成更误导的 ACL 文案。平台判据以 `project_write_lock_open_failure_for(platform, error)` 保留、平台由参数传入而不是 `#[cfg]`:CI 只有 Linux runner,Windows 分支必须在 Linux 上也能断言。
|
||||
- Windows 上真实 ACL 拒绝与删除拆链窗口在错误码上不可区分,所以终态改判放到**等待预算耗尽之后**:`ProjectWriteLockFailure::exhausted_projection(waited)` 只在“真的等过预算 + 目标此刻仍不存在 + 错误码是 `ACCESS_DENIED(5)`”三个条件同时成立时才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_...`)没有等待证据,保持争用语义。代价是 Windows 上真实 ACL 拒绝会先等满等待窗口(约 10 秒)才报权限错误;Unix 的 `EACCES` 立即判定、不等待。
|
||||
- 重试与否改由**类型**决定,不再解析错误文案:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装。零等待入口前缀不变,只在“错误码不可区分且目标此刻不存在”时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,把两种处置都交给调用方,而不是替它猜一个。
|
||||
- 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。
|
||||
- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted`(`waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。
|
||||
- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker(`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。
|
||||
- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。
|
||||
|
||||
## 2026-09-11 AGC 登录态自动续期
|
||||
|
||||
- AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。
|
||||
- DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。
|
||||
|
||||
@@ -1896,7 +1896,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析
|
||||
| 现役 pending wire | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:26-27`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs:3-46,219-270`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs:290-520` | 保持 `game-creator-pending-action.v5`;M1 另建 planning pending,不升级全局 wire |
|
||||
| submit 专用提交/恢复分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs` | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1 |
|
||||
| JSON sidecar | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250` | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper |
|
||||
| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 |
|
||||
| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 |
|
||||
| completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs` | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层根 Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用 |
|
||||
| plan 根 run 子 Agent 创建面 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs`(`observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs`(`validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs`(`game_creator_project_supervisor_tool_plan_prompt`) | **2026-08-14 `M1A-4` 已落地**:plan 根 run 只能委派 `project-planning`,`agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`;plan source 下不拼 `supervisorIntro` 与 `$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**:`$base` 的 `$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句 |
|
||||
| plan retry | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs`(`resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs`(`supervisor_plan_root_identity_holds_at`) | **2026-08-13 `M1A-3` 已落地 source 保源**:`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*`) |
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Issue #226:跨 origin 重定向标记泄漏复审修复
|
||||
|
||||
更新时间:2026-09-02
|
||||
关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计`
|
||||
|
||||
## 1. 修复结论
|
||||
|
||||
采纳评审意见:AGC 主站 Client 的默认重定向策略不能继续使用 reqwest 的“任意 origin 默认跟随”,否则 `default_headers` 中的 `X-Genarrative-Client: agc` 可能被复制到 OSS 或第三方 origin。
|
||||
|
||||
本次修复将工厂默认策略调整为:
|
||||
|
||||
- 目标 URL 与原始请求 URL 的完整 origin(scheme + host + effective port)相同:委托 reqwest 默认 redirect policy。
|
||||
- origin 不同:`stop`,返回当前 3xx,不发起下一跳请求。
|
||||
- 无法取得原始 URL 或 origin 无法确认:按 fail-closed 处理,停止重定向。
|
||||
|
||||
这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。
|
||||
|
||||
另针对评审发现的同名 Header 覆盖缺口,补充了主站请求终结器:`default_headers` 继续负责普通请求的默认注入;所有已审计的 AGC 主站请求在发送前统一调用 `with_agc_main_site_marker`,使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,确保最终值固定为 `agc`。
|
||||
|
||||
## 2. 修改范围
|
||||
|
||||
修改 AGC Rust HTTP Client factory、主站请求终结器、已审计主站直发调用点和定向测试:
|
||||
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs`
|
||||
- 已审计主站请求所在的 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`project/asset_canvas/generation.rs`、`project/resource_editor.rs`
|
||||
- 本地实施方案、阶段计划和本复审记录
|
||||
|
||||
不修改:
|
||||
|
||||
- #225 的主站 tracking、数据库和后台代码。
|
||||
- OSS/签名下载、Provider、受控搜索、loopback、更新下载等第三方 Client。
|
||||
- 各业务调用点的 timeout、connect timeout、认证、幂等或请求体逻辑。
|
||||
|
||||
## 3. 重定向行为契约
|
||||
|
||||
| 场景 | 处理 | 标记是否到达下一跳 |
|
||||
|---|---|---:|
|
||||
| 同 scheme、host、effective port | 继续按 reqwest 默认策略 | 是,仍是主站 origin |
|
||||
| scheme、host 或端口任一不同 | 返回当前 3xx,不 follow | 否,不发起请求 |
|
||||
| 调用点显式 `Policy::none()` | 继续不 follow | 否 |
|
||||
|
||||
使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。
|
||||
|
||||
## 4. 同名 Header 覆盖契约
|
||||
|
||||
`reqwest` 的 `ClientBuilder::default_headers` 只会为请求补充缺失字段,请求级同名 Header 默认优先。因此不能把 `default_headers` 单独当作“不可伪造”的约束。
|
||||
|
||||
当前实现由两层组成:
|
||||
|
||||
- factory 设置 `default_headers`,覆盖普通未显式设置 Header 的请求;
|
||||
- `with_agc_main_site_marker(request)` 在主站请求发送前用 `RequestBuilder::headers` 替换同名字段。`external_editor_json_request` 已内置该终结器,直接 `.send()` 的主站路径也显式经过该终结器。
|
||||
|
||||
这样既保留方案一的 Client factory 形态,又满足“调用方传入同名 Header 时最终仍为 `X-Genarrative-Client: agc`”的冻结契约。
|
||||
|
||||
## 5. 测试证据
|
||||
|
||||
新增或调整 `http_client` 定向测试,覆盖:
|
||||
|
||||
- 完整 origin 比较:默认端口等价,scheme/host/非默认端口变化视为跨 origin。
|
||||
- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。
|
||||
- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。
|
||||
- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。
|
||||
- 请求级伪造同名 Header:终结器覆盖调用方值,服务端只收到 `agc`。
|
||||
|
||||
验证命令及结果:
|
||||
|
||||
```text
|
||||
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture
|
||||
8 tests passed
|
||||
```
|
||||
|
||||
## 6. 对 #225 的交接
|
||||
|
||||
本修复不改变 Header 契约:
|
||||
|
||||
```http
|
||||
X-Genarrative-Client: agc
|
||||
```
|
||||
|
||||
主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。
|
||||
|
||||
## 7. 后续限制
|
||||
|
||||
`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy,或新增请求时绕过终结器。当前已审计生产调用点均已经过终结器,只有显式 `Policy::none()` 的 redirect 覆盖,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。
|
||||
@@ -1,342 +0,0 @@
|
||||
# Issue #225:AGC 主站请求标记埋点统计实施方案
|
||||
|
||||
更新时间:2026-09-02
|
||||
关联 Issue:
|
||||
|
||||
- `#225 添加客户端埋点统计`:本文全部实施范围
|
||||
- `#226 添加客户端特殊标识`:客户端侧已完成;本文只接收其固定交接契约,不回改客户端
|
||||
|
||||
当前状态:阶段 0~6 已完成。主站生产代码和定向测试已按阶段提交;阶段 6 仅补充最终门禁、交接与验收文档。未修改 SpacetimeDB schema、OpenAPI 或后台页面。
|
||||
|
||||
## 1. 一句话交付结果
|
||||
|
||||
主站 `api-server` 能识别 AGC 发来的 `X-Genarrative-Client: agc`,并在本期新增的 AGC 专用成功路由埋点写入 `tracking_event.metadata_json` 的 `client: "agc"`,同时按真实登录用户或 External API Key 的 `owner_user_id` 归属,后台可以通过现有 tracking 事件查询看到这类请求;既有 route tracking 和手工资产事件保持原有全客户端统计口径,不改变鉴权、计费、幂等和响应语义。
|
||||
|
||||
## 2. Issue 边界
|
||||
|
||||
### 2.1 本次只做 #225
|
||||
|
||||
本次主改动限定在主站后端:
|
||||
|
||||
- `server-rs/crates/api-server/src/app.rs` 的 tracking middleware 入口。
|
||||
- `server-rs/crates/api-server/src/tracking.rs` 的标记识别、主体归属、metadata 合并和 route tracking 覆盖。
|
||||
- 必要的 `api-server` 定向测试、External v1 路由测试和后台 tracking 读取验证。
|
||||
- 方案、验收、交接文档。
|
||||
|
||||
### 2.2 本次不做 #226 的回改
|
||||
|
||||
不修改:
|
||||
|
||||
- AGC TS 的 `fetchClientHttp` 标记注入。
|
||||
- AGC Rust 主站 Client factory、请求终结器和 redirect policy。
|
||||
- AGC 主站/第三方请求边界、timeout、认证、幂等和请求体语义。
|
||||
|
||||
`#226` 已冻结并合入的客户端契约直接作为本 Issue 的输入。
|
||||
|
||||
### 2.3 明确不做项
|
||||
|
||||
- 不新增 `tracking_event` 列、索引、migration 或生成 bindings;第一阶段复用已有 `metadata_json`。
|
||||
- 不新增平行的 `agc_client_request` 事件、平行表或平行统计口径。
|
||||
- 不改变 `/api/external/v1` 的路由、HTTP 方法、DTO、状态码、鉴权或异步语义,因此不改 OpenAPI 契约。
|
||||
- 不把 `generationInputs.source`、User-Agent、请求体中的 owner 字段当作客户端来源或主体。
|
||||
- 不记录 access token、External API Key 明文、Cookie、签名 URL、项目绝对路径或其他秘密。
|
||||
- 不把 OSS 上传、签名 URL 实际下载、LLM/Codex Provider、受控搜索、loopback、更新下载和任意外部网页请求当成主站业务 tracking。
|
||||
- 不默认把失败响应改造成新的 tracking 事实;先保持现有“成功响应才写 route tracking”语义。
|
||||
- 不默认新增后台筛选控件;后台先复用现有 tracking 原始事件查询展示 `metadata_json`。
|
||||
|
||||
## 3. 当前实现基线与缺口
|
||||
|
||||
### 3.1 现有 tracking 流程
|
||||
|
||||
当前全局链路位于:
|
||||
|
||||
- `server-rs/crates/api-server/src/app.rs`
|
||||
- `server-rs/crates/api-server/src/tracking.rs`
|
||||
|
||||
流程为:
|
||||
|
||||
```text
|
||||
请求进入
|
||||
→ tracking middleware 保存 method/path
|
||||
→ next.run(request)
|
||||
→ 从 response extensions 读取认证主体
|
||||
→ 仅对成功响应解析 RouteTrackingSpec
|
||||
→ 生成 TrackingEventDraft.metadata
|
||||
→ 本机 tracking outbox
|
||||
→ SpacetimeDB tracking_event / tracking_daily_stat
|
||||
```
|
||||
|
||||
现有 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 都会在认证 middleware 成功后写入 response extensions;但 tracking middleware 目前只消费前者。
|
||||
|
||||
### 3.2 当前缺口
|
||||
|
||||
- tracking middleware 尚未读取 `X-Genarrative-Client`。
|
||||
- route metadata 尚未写入 `client`。
|
||||
- External API Key 的 `ExternalApiPrincipal.owner_user_id` 尚未接入 route tracking 归属。
|
||||
- `/api/external/v1/*` 没有完整的 route tracking spec。
|
||||
- 账号态实际被 AGC 使用的 `/api/editor`、`/api/assets`、`/api/runtime` 路径也有未覆盖项。
|
||||
|
||||
## 4. 冻结的 #226 交接契约
|
||||
|
||||
### 4.1 Header 识别
|
||||
|
||||
```http
|
||||
X-Genarrative-Client: agc
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- Header 名按 HTTP 规则大小写不敏感。
|
||||
- 值去除首尾空白后,精确等于小写 `agc` 才认定为 AGC。
|
||||
- 缺失、空值、`AGC`、其他未知值均按“未标记”处理。
|
||||
- 未标记请求不被拒绝,也不改变业务行为。
|
||||
- Header 只用于来源审计和统计,不参与鉴权、权限、计费、幂等或账号归属。
|
||||
|
||||
### 4.2 metadata 形态
|
||||
|
||||
第一阶段复用已有 `tracking_event.metadata_json`,在原对象上追加固定键:
|
||||
|
||||
```json
|
||||
{
|
||||
"route": "/api/editor/images/generations",
|
||||
"method": "POST",
|
||||
"status": 202,
|
||||
"operation": "generateExternalEditorImage",
|
||||
"client": "agc"
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- JSON key 固定为 `client`,值固定为 `agc`。
|
||||
- 有效标记时追加 `client`;未标记时不写 `client`,不写空字符串或 `null`。
|
||||
- 已有 `route`、`method`、`status`、`operation` 以及资产类嵌套 metadata 必须保留。
|
||||
- `route` 使用主站实际收到的 method/path;External v1 不得只记录客户端账本中的内部映射路径。
|
||||
|
||||
### 4.3 认证主体归属
|
||||
|
||||
| 请求类型 | `user_id` | `owner_user_id` | `scope_kind/scope_id` | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 登录账号态 | 沿用 `AuthenticatedAccessToken.claims().user_id()` | 沿用现有行为,通常与 user_id 相同 | 沿用 route spec;User scope 使用真实用户 | Header 不能覆盖真实认证主体 |
|
||||
| External API Key 态 | 不伪造登录用户,可为空 | `ExternalApiPrincipal.owner_user_id()` | User scope 使用 owner_user_id | 归属 API Key 所属账号 |
|
||||
| 无认证的公开/站点请求 | 为空 | 为空 | 沿用 Site/公开 route spec | 只在已有公开 route spec 时记录 |
|
||||
|
||||
第一阶段不把 API Key 明文写入 metadata。`key_id` 是安全的内部标识,但只有在后续明确需要按单个 Key 统计或审计时才增加 `externalApiKeyId`,不作为本期客户端交接前提。
|
||||
|
||||
### 4.4 成功与失败语义
|
||||
|
||||
本期先保留当前 route tracking 的成功响应语义:
|
||||
|
||||
- 2xx 成功响应按 route spec 写入 tracking。
|
||||
- 3xx、4xx、5xx 和 transport failure 不因为 AGC 标记而自动新增 route 事件。
|
||||
- 生成提交、异步轮询、重试请求使用同一标记;是否落库仍由现有 route tracking 规则决定。
|
||||
|
||||
如果产品后续要求统计失败调用,应另立失败事件的 event key、幂等键、容量和报表口径,不在本期隐式扩展。
|
||||
|
||||
## 5. 推荐实现:扩展现有 route tracking
|
||||
|
||||
本期采用方案一:在现有 route tracking 上追加来源和主体信息,不创建通用 AGC 请求事件。
|
||||
|
||||
```text
|
||||
请求 Header
|
||||
→ tracking middleware 在 next.run 前白名单解析 marker
|
||||
→ next.run(request)
|
||||
→ 从 response extensions 读取 AuthenticatedAccessToken / ExternalApiPrincipal
|
||||
→ 按实际 method + path 解析 RouteTrackingSpec
|
||||
→ 计算 user/owner/scope
|
||||
→ 在现有 metadata 对象追加 client=agc
|
||||
→ 复用现有 outbox、tracking_event、tracking_daily_stat
|
||||
→ 现有后台 tracking 查询读取 metadata_json
|
||||
```
|
||||
|
||||
### 5.1 为什么不新增通用 AGC 事件
|
||||
|
||||
- 不会与现有 route event 重复计数。
|
||||
- 不会改变 `tracking_daily_stat` 的 event key 和历史统计口径。
|
||||
- 仍能看到实际 path、method、status、operation 和 client 的组合。
|
||||
- 继续复用现有 outbox、SpacetimeDB procedure 和后台原始事件查询。
|
||||
- 新增或确认 AGC 路由时只需补齐 route spec 和测试,不需要引入第二套事件系统。
|
||||
|
||||
代价是 route coverage 必须显式审计;因此阶段 3 将以 AGC 客户端调用清单和 External v1 router/OpenAPI 交叉核对,禁止“标记已经发送但主站没有 route spec”的漏项。
|
||||
|
||||
### 5.2 实现边界
|
||||
|
||||
推荐保持最小抽象:
|
||||
|
||||
- `app.rs` 在消费 request 之前解析 marker,并从 response extensions 取两类主体。
|
||||
- `tracking.rs` 提供小型白名单解析函数和主体归属逻辑。
|
||||
- `RouteTrackingSpec` 明确区分既有全客户端 route 与本期新增的 AGC-only route;新增 AGC route 只有在 marker 有效时才允许落库。
|
||||
- `build_route_tracking_metadata` 只在有效 marker 时追加 `client`,不重写既有字段。
|
||||
- `record_route_tracking_event_after_success` 继续负责 route spec、outbox 和失败日志策略。
|
||||
- 不把 marker 放进 `RequestContext`,除非阶段 1 证明同一请求的其他统一 tracking 入口确实需要它;避免扩大公共上下文结构。
|
||||
|
||||
既有 route spec 默认保持全客户端语义;本期新增、仅为 AGC 调用清单补齐的 route spec 使用 AGC-only 策略。已有 `handled_by_existing_event` 的详细资产事件不改记录范围,只在有效 marker 时追加 `client`。
|
||||
|
||||
## 6. Route 覆盖范围
|
||||
|
||||
### 6.1 必须覆盖的账号态路径
|
||||
|
||||
按 AGC 当前扫描清单,至少核对并覆盖:
|
||||
|
||||
- `/api/auth/*`:当前登录用户查询、refresh、登录入口、发送验证码、手机号登录、logout 等实际调用。
|
||||
- `/api/profile/*`:dashboard、recharge center、recharge order/confirm、wallet ledger、API Key 管理。
|
||||
- `/api/assets/*`:direct-upload-tickets、objects/confirm、read-url、read-bytes 以及 AGC 实际使用的资产操作。
|
||||
- `/api/editor/*`:projects、assets/library、assets/folders、项目 resources、图片/编辑/去背景/图集/角色动画/视频/音效/BGM 生成和轮询相关内部路径。
|
||||
- `/api/runtime/external-generation/jobs/{operationId}`:账号态生成任务轮询。
|
||||
|
||||
已有 route spec 的 event key 和 scope 语义保持不变;新增项使用稳定、可读、与路径/operation 对应的 event key。
|
||||
|
||||
### 6.2 必须覆盖的 External API Key 业务路径
|
||||
|
||||
按 `server-rs/crates/api-server/src/modules/external_api.rs` 和实际 AGC 使用情况核对:
|
||||
|
||||
- `/api/external/v1/assets/direct-upload-tickets`
|
||||
- `/api/external/v1/assets/objects/confirm`
|
||||
- `/api/external/v1/assets/read-url`
|
||||
- `/api/external/v1/editor/projects` 及项目读取/资源登记相关路径
|
||||
- `/api/external/v1/editor/assets/library`、folders、asset CRUD 中实际被 AGC 使用的路径
|
||||
- `/api/external/v1/editor/images/generations`
|
||||
- `/api/external/v1/editor/images/edits`
|
||||
- `/api/external/v1/editor/images/background-removals`
|
||||
- `/api/external/v1/editor/icon-spritesheets/generations`
|
||||
- `/api/external/v1/editor/character-animations/generations`
|
||||
- `/api/external/v1/editor/videos/generations`
|
||||
- `/api/external/v1/editor/audios/sound-effects/generations`
|
||||
- `/api/external/v1/editor/audios/background-music/generations`
|
||||
- `/api/external/v1/generations/{operationId}`
|
||||
|
||||
External v1 路由的 tracking 必须记录外部实际 path,不把它改写成 `/api/editor`、`/api/assets` 或 `/api/runtime`。
|
||||
|
||||
### 6.3 明确排除的 External v1 路径
|
||||
|
||||
以下是公开发现或集成协议入口,不属于普通 AGC 主站业务调用,本期不新增普通 route tracking spec:
|
||||
|
||||
- `GET /api/external/v1/openapi.json`
|
||||
- `GET /api/external/v1/agent-integration.json`
|
||||
- `GET /api/external/v1/skill/SKILL.md`
|
||||
- `GET /api/external/v1/skill.zip`
|
||||
- `POST /api/external/v1/mcp`
|
||||
|
||||
如果未来 AGC 明确接入远程 MCP/Skill,再单独定义集成流量的事件语义,不把它们隐式混入普通业务统计。
|
||||
|
||||
## 7. 数据库与后台承接
|
||||
|
||||
### 7.1 数据库
|
||||
|
||||
沿用现有链路:
|
||||
|
||||
```text
|
||||
TrackingEventDraft.metadata
|
||||
→ RuntimeTrackingEventInput.metadata_json
|
||||
→ tracking outbox
|
||||
→ record_tracking_event procedure
|
||||
→ tracking_event.metadata_json
|
||||
```
|
||||
|
||||
不增加字段、不改 migration、不重新生成 bindings。现有 JSON object 校验足以容纳 `client` 键;`tracking_daily_stat` 继续按原 event key/scope/day 聚合,不按 client 另建统计表。
|
||||
|
||||
### 7.2 后台
|
||||
|
||||
现有后台 tracking 查询已经返回 `metadata_json`,第一阶段只做数据可见性验证:
|
||||
|
||||
- 管理员能在原始事件详情中看到 `client: "agc"`。
|
||||
- 原有 event key、scope、user/owner、日期查询不回归。
|
||||
- 不新增客户端筛选器、导出列或新的后台路由。
|
||||
|
||||
如果后续需要高频按 client 筛选,再评估新增结构化列和索引;那将是独立 schema 变更,不应在本期偷偷引入。
|
||||
|
||||
## 8. 安全与兼容性约束
|
||||
|
||||
- 来源 Header 是可伪造的审计标签,不能当作安全边界。
|
||||
- 主体必须来自已验证的 `AuthenticatedAccessToken` 或 `ExternalApiPrincipal`,不能信任请求体 owner、客户端账本或 Header 中的身份。
|
||||
- 不记录 Bearer token、API Key 原文、Cookie、签名 URL 或请求体中的敏感字段。
|
||||
- Header 缺失/未知时继续走原有业务和 tracking 逻辑,只是不追加 `client`。
|
||||
- metadata 合并必须保留既有资产嵌套字段,不得用新对象覆盖旧 metadata。
|
||||
- outbox 入队、SpacetimeDB 写入失败继续只记录 warning,不阻断主业务响应。
|
||||
- 不修改 route normalization、dynamic id 归一化、event id 幂等或 daily stat 聚合规则。
|
||||
|
||||
## 9. 定向测试方案
|
||||
|
||||
### 9.1 Marker 解析
|
||||
|
||||
表驱动测试覆盖:
|
||||
|
||||
- `X-Genarrative-Client: agc` → `Some("agc")`。
|
||||
- Header 名大小写变化 → 仍识别。
|
||||
- 缺失、空值、首尾空白、`AGC`、未知值 → 未标记。
|
||||
|
||||
### 9.2 Metadata 与主体归属
|
||||
|
||||
- 有效 marker 的账号态成功路由:metadata 含 `client: "agc"`,user_id/owner_user_id 保持真实用户。
|
||||
- 有效 marker 的 External v1 成功路由:metadata 含 `client: "agc"`,owner_user_id 等于 `ExternalApiPrincipal.owner_user_id()`,scope_id 不落到 `anonymous`。
|
||||
- 未标记请求:metadata 不含 `client`。
|
||||
- 请求体伪造 owner 或 marker 不改变主体归属。
|
||||
- metadata 原有 route/method/status/operation 和资产嵌套字段仍存在。
|
||||
|
||||
### 9.3 AGC-only 记录门禁
|
||||
|
||||
- 本期新增的账号态和 External v1 AGC route:带有效 marker 的 2xx 响应才记录。
|
||||
- 同一批新增 route:缺失、非法或未知 marker 时不记录,也不拒绝业务请求。
|
||||
- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。
|
||||
- 4xx/5xx 即使带有效 marker 也不新增成功 route event。
|
||||
- `build_router` middleware 集成测试验证 marker、账号认证主体、成功 route tracking 和隔离 outbox 可以串联,且 metadata 保留 `client`、实际 route 和 user/owner。
|
||||
|
||||
### 9.4 Route 覆盖
|
||||
|
||||
- 当前 AGC 调用清单中的账号态路径全部能解析到 route spec。
|
||||
- 当前 AGC 使用的 External v1 业务路径全部能解析到 route spec。
|
||||
- `/api/external/v1` 发现、Skill 和 MCP 路径不被普通业务 spec 误收录。
|
||||
- dynamic project/asset/operation ID 仍按现有 normalize 规则归一化。
|
||||
|
||||
### 9.5 持久化与后台读取
|
||||
|
||||
- 构造 tracking event input 后,`metadata_json` 是合法 JSON object 且含 `client`。
|
||||
- outbox 入队/回退直写路径不丢失 `client`。
|
||||
- tracking_event 读回及后台 tracking API 解析不丢失 metadata。
|
||||
- 现有 daily stat、event id 幂等和失败不阻断语义保持不变。
|
||||
|
||||
### 9.6 状态码语义
|
||||
|
||||
至少保留一组回归:
|
||||
|
||||
- 2xx 成功响应写入 route tracking。
|
||||
- 4xx/5xx 不因为 marker 自动新增成功 route event。
|
||||
- 认证失败不伪造 ExternalApiPrincipal 或用户归属。
|
||||
- 轻量 middleware 成功链路测试通过;不依赖真实 SpacetimeDB,不纳入完整环境型 E2E。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
1. 阶段 0:确认基线、交接契约、路径边界和不做项。
|
||||
2. 阶段 1:加入 marker 白名单解析,并将解析结果传入现有 route tracking。
|
||||
3. 阶段 2:接入 `AuthenticatedAccessToken` / `ExternalApiPrincipal`,完成 owner/user/scope 归属和 metadata 合并。
|
||||
4. 阶段 3:按 AGC 调用清单补齐账号态与 External v1 业务 route spec,明确排除发现/MCP。
|
||||
5. 阶段 4:验证 outbox、SpacetimeDB `tracking_event` 和现有后台原始查询,无 schema 变更。
|
||||
6. 阶段 5:完成 marker、主体、route 覆盖、状态码、安全和持久化定向测试。
|
||||
7. 阶段 6:执行最终门禁,更新交接材料并把结果回传 Issue #225;不要求 #226 回改。
|
||||
|
||||
## 11. 完成判据
|
||||
|
||||
只有同时满足以下条件,#225 才算完成:
|
||||
|
||||
- 主站精确识别 `X-Genarrative-Client: agc`,未知/缺失值不拒绝请求。
|
||||
- 有效标记进入现有 route tracking metadata,写入 `client: "agc"`。
|
||||
- 已有 route、method、status、operation、资产 metadata 和 event key 语义不丢失。
|
||||
- 登录账号态按真实用户归属,External API Key 态按 `owner_user_id` 归属。
|
||||
- 当前 AGC 实际使用的账号态和 External v1 业务路径均有 tracking spec。
|
||||
- 本期新增的 AGC route spec 未携带有效 marker 时不产生 route tracking event;既有 route spec 和手工资产事件保持原有全客户端统计语义。
|
||||
- External v1 discovery/Skill/MCP、OSS、签名下载、Provider、搜索、loopback、更新下载不被误纳入普通 AGC 业务统计。
|
||||
- outbox、SpacetimeDB 写入、daily stat、幂等和失败不阻断语义无回归。
|
||||
- 后台现有 tracking 查询可以看到 `metadata_json.client`,无需新增 schema 或页面。
|
||||
- 定向测试和必要的格式/编码/空白检查通过。
|
||||
|
||||
## 12. 评审与提交节奏建议
|
||||
|
||||
建议按阶段分组提交或至少在一个 PR 中保持以下逻辑顺序:
|
||||
|
||||
1. 阶段 0 文档与契约冻结。
|
||||
2. marker 解析、metadata 合并和账号态主体接入。
|
||||
3. External API Key 主体接入与 External v1 route spec。
|
||||
4. 持久化/后台读取验证和定向测试。
|
||||
5. 最终文档、交接评论和门禁记录。
|
||||
|
||||
每个阶段先通过自身验收,再进入下一阶段;不需要等待 #226 再次修改,也不需要先新增数据库字段。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user