Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0388fa0912 | |||
| 79b153e3fa | |||
| 362edcc49d | |||
| 1e434e0cb5 | |||
| cec971c438 | |||
| 70157673b6 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 0594a90bdd | |||
| aee862532c | |||
| 42be8ea060 | |||
| b48293fb1f | |||
| e08171fd2e | |||
| 479120d368 | |||
| 4182979a19 | |||
| 737a2266b9 | |||
| 262deaf9b7 | |||
| c36a5170f8 | |||
| 9663bbf911 |
@@ -7,6 +7,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
|
||||
interface MockIntersectionObserverController {
|
||||
enter: (target: Element) => void;
|
||||
enterAll: (targets: Element[]) => void;
|
||||
isObserved: (target: Element) => boolean;
|
||||
}
|
||||
|
||||
@@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
|
||||
);
|
||||
});
|
||||
},
|
||||
enterAll(targets) {
|
||||
act(() => {
|
||||
for (const target of targets) {
|
||||
const record = observed.get(target);
|
||||
if (!record) {
|
||||
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
||||
}
|
||||
record.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
record.observer,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
isObserved(target) {
|
||||
return observed.has(target);
|
||||
},
|
||||
@@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
const thumbnails = entries.map((entry) =>
|
||||
thumbnailElementForLabel(entry.label),
|
||||
);
|
||||
thumbnails.forEach((thumbnail) => {
|
||||
for (const thumbnail of thumbnails) {
|
||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||
observer.enter(thumbnail);
|
||||
});
|
||||
}
|
||||
observer.enterAll(thumbnails);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||
const observer = installIntersectionObserverMock();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.47"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.47"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
|
||||
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
match self {
|
||||
Self::PlatformSession { .. } => None,
|
||||
#[cfg(test)]
|
||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||
#[cfg(test)]
|
||||
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
|
||||
.as_deref()
|
||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||
#[cfg(not(test))]
|
||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||
Self::AuthBridge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +195,20 @@ impl CodexAppServerStderrSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// 派发 app-server 的收尾与中断任务。
|
||||
///
|
||||
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
|
||||
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
|
||||
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
|
||||
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor
|
||||
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
|
||||
/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER
|
||||
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast
|
||||
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。
|
||||
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
|
||||
tauri::async_runtime::spawn(task);
|
||||
}
|
||||
|
||||
struct CodexTurnStartCancellation {
|
||||
inner: Weak<CodexAppServerInner>,
|
||||
thread_id: String,
|
||||
@@ -257,7 +270,7 @@ impl CodexTurnStartCancellation {
|
||||
};
|
||||
let connection = CodexAppServerConnection { inner };
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let _ = connection
|
||||
.request(
|
||||
"turn/interrupt",
|
||||
@@ -2018,7 +2031,13 @@ impl CodexAppServerConnection {
|
||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
let mut effective_llm = llm.clone();
|
||||
let credential = if game_creator_official_llm_route_locked() {
|
||||
let credential = if llm.custom_enabled {
|
||||
crate::config::validate_custom_llm_connection(llm)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
||||
}
|
||||
} else if game_creator_official_llm_route_locked() {
|
||||
let session = current_platform_session().ok_or_else(|| {
|
||||
platform_llm::LlmError::InvalidConfig(
|
||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||
@@ -2186,7 +2205,8 @@ impl CodexAppServerConnection {
|
||||
true,
|
||||
),
|
||||
_ => (
|
||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
(llm.custom_enabled
|
||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||
@@ -3560,7 +3580,7 @@ impl Drop for CodexThreadLease {
|
||||
let connection = self.connection.clone();
|
||||
let key = self.key.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
let mut threads = connection.inner.threads.lock().await;
|
||||
if let Some(entry) = threads.get_mut(&key) {
|
||||
if entry.thread_id == thread_id {
|
||||
@@ -3587,7 +3607,7 @@ impl Drop for CodexTurnGuard {
|
||||
let connection = self.connection.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
let turn_id = self.turn_id.clone();
|
||||
tokio::spawn(async move {
|
||||
spawn_codex_app_server_task(async move {
|
||||
connection.inner.turns.lock().await.remove(&turn_id);
|
||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||
let _ = connection
|
||||
@@ -4483,6 +4503,19 @@ mod tests {
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
|
||||
#[test]
|
||||
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
|
||||
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
|
||||
assert!(
|
||||
joined.join().is_ok(),
|
||||
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
|
||||
);
|
||||
}
|
||||
|
||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||
#[test]
|
||||
@@ -4743,6 +4776,8 @@ mod tests {
|
||||
|
||||
fn test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
@@ -5528,6 +5563,59 @@ mod tests {
|
||||
assert_ne!(command_token, provider_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
|
||||
let mut llm = test_llm();
|
||||
llm.custom_enabled = true;
|
||||
llm.api_key = "custom-upstream-fixture-secret".into();
|
||||
llm.base_url = "http://127.0.0.1:9/v1".into();
|
||||
llm.model = "vendor/model.v1:latest".into();
|
||||
llm.visible_models = vec![llm.model.clone()];
|
||||
let credential = CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: "custom-fixture".into(),
|
||||
};
|
||||
let (base, key) = credential
|
||||
.direct_provider_route(&llm)
|
||||
.expect("custom route");
|
||||
assert_eq!(base, llm.base_url);
|
||||
assert_eq!(key, llm.api_key);
|
||||
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
|
||||
for mode in [
|
||||
CodexAppServerWorkspaceMode::DirectProject,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
] {
|
||||
let mut command = tokio::process::Command::new("fixture");
|
||||
configure_game_creator_codex_app_server_command_for_mode(
|
||||
&mut command,
|
||||
&llm,
|
||||
mode,
|
||||
Some(&proxy),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let arguments = command
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let params = codex_app_server_thread_start_params(
|
||||
&llm.model,
|
||||
std::path::Path::new("fixture-workspace"),
|
||||
mode,
|
||||
String::new(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(params["model"], "vendor/model.v1:latest");
|
||||
assert!(!arguments.contains(&llm.api_key));
|
||||
assert!(!arguments.contains("/api/llm"));
|
||||
for (_, value) in command.as_std().get_envs() {
|
||||
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||
|
||||
@@ -527,10 +527,6 @@ fn process_design_batch(
|
||||
let result = if uncertain {
|
||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||
} else {
|
||||
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"design.tool",
|
||||
)?;
|
||||
execute_design_tool(root, resources, session, &call)
|
||||
};
|
||||
let error = result
|
||||
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
|
||||
finish_design_command(root, resources, session, active, run, emit).await
|
||||
}
|
||||
|
||||
async fn recover_uncertain_design_batch(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
session: DesignSession,
|
||||
active: File,
|
||||
) -> Result<DesignView, String> {
|
||||
finish_design_command(root, resources, session, active, true, |_| {}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn decide_design_phase_at(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn hydrate_design_agent_session(
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
|
||||
if session.project_id != project_id {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
}
|
||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
||||
Ok(Some(design_view(&session, active.is_none())))
|
||||
let Some(active) =
|
||||
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||||
else {
|
||||
return Ok(Some(design_view(&session, true)));
|
||||
};
|
||||
if design_session_has_uncertain_batch(&session) {
|
||||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||||
return Ok(Some(view));
|
||||
}
|
||||
drop(active);
|
||||
Ok(Some(design_view(&session, false)))
|
||||
}
|
||||
|
||||
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
|
||||
let Some(batch) = session.pending_batch.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if !batch.executing || batch.cursor >= batch.calls.len() {
|
||||
return false;
|
||||
}
|
||||
let call_id = batch.calls[batch.cursor].id.as_str();
|
||||
session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||
&& !session.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||
@@ -1958,4 +1989,94 @@ mod tests {
|
||||
.any(|message| message.text.contains("重试后继续")));
|
||||
assert!(next.session.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
execute_design_file_tool(
|
||||
&root,
|
||||
"write_file",
|
||||
&json!({"path":"project/00_concept/design.md","content":"概念"}),
|
||||
)
|
||||
.expect("write concept");
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
let call = platform_llm::LlmToolCall {
|
||||
id: "interrupted-call".into(),
|
||||
name: "patch_file".into(),
|
||||
arguments: json!({
|
||||
"path":"project/00_concept/design.md",
|
||||
"old_text":"概念",
|
||||
"new_text":"概念设计"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
session.history.push(json!({
|
||||
"type":"function_call",
|
||||
"call_id":call.id,
|
||||
"name":call.name,
|
||||
"arguments":call.arguments,
|
||||
}));
|
||||
session.messages = vec![DesignMessage {
|
||||
id: "turn:user".into(),
|
||||
role: "user".into(),
|
||||
text: "继续".into(),
|
||||
}];
|
||||
session.turn = Some(DesignTurn {
|
||||
id: "turn-recovery".into(),
|
||||
pending: true,
|
||||
request_index: 0,
|
||||
attempt: 0,
|
||||
});
|
||||
session.pending_batch = Some(DesignToolBatch {
|
||||
calls: vec![call],
|
||||
cursor: 0,
|
||||
executing: true,
|
||||
});
|
||||
assert!(design_session_has_uncertain_batch(&session));
|
||||
write_design_session(&root, &session).expect("write interrupted session");
|
||||
|
||||
let _fake = fake_provider::install(
|
||||
vec![Ok(fake_response(
|
||||
"recovered-after-uncertain-tool",
|
||||
"已读取文件并确认。",
|
||||
Vec::new(),
|
||||
))],
|
||||
0,
|
||||
);
|
||||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||||
try_open_game_creator_agent_runtime_task_lock_file(
|
||||
&root,
|
||||
".agent/design-agent/active.lock",
|
||||
)
|
||||
.expect("open active lock")
|
||||
.expect("active lock is free")
|
||||
})
|
||||
.await
|
||||
.expect("recover uncertain batch");
|
||||
|
||||
assert!(!view.running);
|
||||
assert!(view.session.last_error.is_none());
|
||||
let restored = read_design_session(&root)
|
||||
.expect("read restored")
|
||||
.expect("session");
|
||||
assert!(restored.pending_batch.is_none());
|
||||
assert!(!restored.turn.expect("turn").pending);
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
|
||||
&& item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|output| output.contains("执行结果未保存"))
|
||||
}));
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& item.get("content").is_some()
|
||||
}));
|
||||
assert!(
|
||||
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
|
||||
.expect("read target")
|
||||
== "概念"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = Vec::new();
|
||||
let mut edit_errors = Vec::new();
|
||||
let mut valid_edits = 0;
|
||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
||||
if old == new {
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let count = content.matches(old).count();
|
||||
if count == 0 {
|
||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文未找到:{}{}",
|
||||
display,
|
||||
design_patch_location_hint(&content, old)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if count != 1 {
|
||||
return Err(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||
let start = content.find(old).expect("count checked");
|
||||
let line = design_patch_line_number(&content, start);
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let start = content.find(old).expect("count checked");
|
||||
let end = start + old.len();
|
||||
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
|
||||
.iter()
|
||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||
{
|
||||
return Err(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
matches.push((index, start, end));
|
||||
valid_edits += 1;
|
||||
let _ = new;
|
||||
}
|
||||
if !edit_errors.is_empty() {
|
||||
let shown = edit_errors.len().min(4);
|
||||
let mut details = edit_errors[..shown].to_vec();
|
||||
if shown < edit_errors.len() {
|
||||
details.push(format!(
|
||||
"另有 {} 个 edit 校验失败(详情省略)",
|
||||
edit_errors.len() - shown
|
||||
));
|
||||
}
|
||||
if valid_edits > 0 {
|
||||
details.push(format!(
|
||||
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
|
||||
));
|
||||
} else {
|
||||
details.push("本次未写入文件".to_string());
|
||||
}
|
||||
return Err(details.join("\n"));
|
||||
}
|
||||
let mut updated = content.clone();
|
||||
for (index, start, end) in matches.into_iter().rev() {
|
||||
let (_, new) = &normalized[index];
|
||||
@@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
}
|
||||
|
||||
fn design_patch_line_number(content: &str, start: usize) -> usize {
|
||||
1 + content[..start]
|
||||
.bytes()
|
||||
.filter(|byte| *byte == b'\n')
|
||||
.count()
|
||||
}
|
||||
|
||||
fn design_patch_visible_line(line: &str) -> String {
|
||||
line.replace('\t', "\\t").chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn design_patch_location_hint(content: &str, old: &str) -> String {
|
||||
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut candidates = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| line.trim() == anchor)
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
|
||||
if let Some(token) = token {
|
||||
candidates = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| line.trim().contains(token))
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
return format!(
|
||||
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
|
||||
content.lines().count()
|
||||
);
|
||||
}
|
||||
|
||||
let details = candidates
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let suffix = if candidates.len() > 2 {
|
||||
format!("等 {} 处", candidates.len())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)")
|
||||
}
|
||||
|
||||
pub(crate) fn list_design_workspace_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||
@@ -693,6 +783,22 @@ mod tests {
|
||||
)
|
||||
.expect_err("escape");
|
||||
assert!(escaped.contains("路径"));
|
||||
let mismatch = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
&json!({
|
||||
"path":"notes/design.md",
|
||||
"edits":[
|
||||
{"old_text":" 游戏设计","new_text":"游戏概念"},
|
||||
{"old_text":"设计","new_text":"方案"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect_err("report all patch failures");
|
||||
assert!(mismatch.contains("edits[0] 原文未找到"));
|
||||
assert!(mismatch.contains("第 1 行:游戏设计"));
|
||||
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
|
||||
assert!(mismatch.contains("本次未写入文件"));
|
||||
let patched = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
||||
root: PathBuf,
|
||||
token: String,
|
||||
session_user_id: String,
|
||||
session_generation: u64,
|
||||
session_identity_generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||
@@ -1241,7 +1241,8 @@ fn external_mcp_session_id(root: &Path) -> String {
|
||||
material.push('\0');
|
||||
material.push_str(&session.user_id);
|
||||
material.push('\0');
|
||||
material.push_str(&session.generation.to_string());
|
||||
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
||||
material.push_str(&session.identity_generation.to_string());
|
||||
}
|
||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
@@ -1759,7 +1760,9 @@ async fn handle_external_mcp_http_request(
|
||||
let Some(session) = current_platform_session() else {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
||||
if session.user_id != state.session_user_id
|
||||
|| session.identity_generation != state.session_identity_generation
|
||||
{
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||
@@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
root,
|
||||
token: token.clone(),
|
||||
session_user_id: session.user_id,
|
||||
session_generation: session.generation,
|
||||
session_identity_generation: session.identity_generation,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(&route, post(handle_external_mcp_http_request))
|
||||
|
||||
@@ -11,6 +11,10 @@ use super::external_generation_state::{
|
||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
use crate::platform_session::{
|
||||
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
||||
PlatformSessionIdentity,
|
||||
};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||
@@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PreparedPlatformSessionFence {
|
||||
user_id: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
access_token_sha256: String,
|
||||
identity: PlatformSessionIdentity,
|
||||
}
|
||||
|
||||
impl PreparedPlatformSessionFence {
|
||||
@@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence {
|
||||
access
|
||||
.frozen_platform_session()
|
||||
.map(|session| PreparedPlatformSessionFence {
|
||||
user_id: session.user_id.clone(),
|
||||
api_base_url: session.api_base_url.clone(),
|
||||
generation: session.generation,
|
||||
access_token_sha256: format!(
|
||||
"{:x}",
|
||||
Sha256::digest(session.access_token.as_bytes())
|
||||
),
|
||||
identity: session.identity(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
let matches = current_platform_session().is_some_and(|session| {
|
||||
session.user_id == self.user_id
|
||||
&& session.api_base_url == self.api_base_url
|
||||
&& session.generation == self.generation
|
||||
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
|
||||
== self.access_token_sha256
|
||||
});
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
||||
validate_platform_session_identity(&self.identity)
|
||||
}
|
||||
|
||||
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&self.user_id,
|
||||
&self.api_base_url,
|
||||
self.generation,
|
||||
&self.access_token_sha256,
|
||||
)
|
||||
acquire_platform_session_identity_lease(&self.identity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10636,7 +10613,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
drop(owner_a_access);
|
||||
drop(frozen_owner_a);
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
||||
.expect("switch to owner B");
|
||||
|
||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||
@@ -10761,8 +10738,14 @@ mod canvas_generation_tests {
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("wait for accepted response");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
||||
.expect("switch platform account after accepted response");
|
||||
install_platform_session(
|
||||
"post-202-user-b",
|
||||
"post-202-token-b",
|
||||
&switch_base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch platform account after accepted response");
|
||||
});
|
||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "art-director".to_string(),
|
||||
|
||||
+2
-1
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
|
||||
base_url,
|
||||
);
|
||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
||||
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"fingerprint-owner-b",
|
||||
"fingerprint-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global session to owner B");
|
||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||
|
||||
+18
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
|
||||
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "finalization-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "finalization-tool-plan-model".to_string(),
|
||||
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-provider-model".to_string(),
|
||||
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-tool-plan-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-tool-plan-model".to_string(),
|
||||
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
||||
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
||||
let root = project.path();
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-generic-retry-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-generic-retry-model".to_string(),
|
||||
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
response_stream_fixture("tool-plan-capacity-preflight-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-capacity-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-capacity-model".to_string(),
|
||||
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "durable-control-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "durable-control-tool-plan-model".to_string(),
|
||||
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-cleanup-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-cleanup-model".to_string(),
|
||||
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "terminal-handoff-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "terminal-handoff-model".to_string(),
|
||||
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "provider-model".to_string(),
|
||||
|
||||
@@ -2,8 +2,9 @@ use super::*;
|
||||
|
||||
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
||||
OnceLock::new();
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
||||
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
|
||||
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
|
||||
> = OnceLock::new();
|
||||
#[cfg(test)]
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
|
||||
@@ -293,8 +294,8 @@ pub(crate) use entrypoints::{
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
||||
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
|
||||
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
||||
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
|
||||
|
||||
fn lock_game_creator_manifest_invalidation_event_sink(
|
||||
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> {
|
||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS
|
||||
.get_or_init(|| Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
@@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
|
||||
install_game_creator_manifest_invalidation_event_sink(sink);
|
||||
register_game_creator_manifest_invalidation_event_sink(sink);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
|
||||
/// 登记一个界面窗口的事件接收端。
|
||||
///
|
||||
/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有
|
||||
/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。
|
||||
pub(crate) fn register_game_creator_manifest_invalidation_event_sink(
|
||||
sink: GameCreatorManifestInvalidationEventSink,
|
||||
) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
|
||||
let mut sinks = lock_game_creator_manifest_invalidation_event_sinks();
|
||||
if let Some(existing) = sinks
|
||||
.iter_mut()
|
||||
.find(|existing| existing.token == sink.token)
|
||||
{
|
||||
*existing = sink;
|
||||
return;
|
||||
}
|
||||
if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX {
|
||||
sinks.remove(0);
|
||||
}
|
||||
sinks.push(sink);
|
||||
}
|
||||
|
||||
fn remove_game_creator_manifest_invalidation_event_sink(token: &str) {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sink().clone()
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.first()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||
fn drop(&mut self) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = None;
|
||||
lock_game_creator_manifest_invalidation_event_sinks().clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
|
||||
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
|
||||
let Some(sink) = sink else {
|
||||
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone();
|
||||
if sinks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let event = GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
};
|
||||
let mut failed_tokens = Vec::new();
|
||||
let mut last_error = None;
|
||||
for sink in &sinks {
|
||||
match relay_game_creator_manifest_invalidation_to_sink(sink, &event) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
// 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。
|
||||
failed_tokens.push(sink.token.clone());
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failed_tokens.is_empty() {
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.retain(|sink| !failed_tokens.contains(&sink.token));
|
||||
}
|
||||
match last_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn relay_game_creator_manifest_invalidation_to_sink(
|
||||
sink: &GameCreatorManifestInvalidationEventSink,
|
||||
event: &GameCreatorManifestInvalidatedEvent,
|
||||
) -> Result<(), String> {
|
||||
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
|
||||
token: sink.token,
|
||||
event: GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
},
|
||||
token: sink.token.clone(),
|
||||
event: event.clone(),
|
||||
};
|
||||
let payload = serde_json::to_vec(&envelope)
|
||||
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
|
||||
|
||||
@@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||||
current_platform_session_generation()
|
||||
pub(crate) fn read_platform_account_session_state(
|
||||
) -> crate::platform_session::PlatformSessionWriteState {
|
||||
crate::platform_session::current_platform_session_write_state()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session(
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||||
validate_platform_session_input(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
identity_generation,
|
||||
revision,
|
||||
)?;
|
||||
install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
identity_generation,
|
||||
revision,
|
||||
)?;
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||||
install_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
identity_generation,
|
||||
revision,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
pub(crate) async fn clear_platform_account_session(
|
||||
identity_generation: u64,
|
||||
revision: u64,
|
||||
) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
shutdown_game_creator_codex_app_servers()?;
|
||||
clear_external_agent_runner_platform_session(generation)?;
|
||||
clear_platform_session(generation);
|
||||
clear_external_agent_runner_platform_session(identity_generation, revision)?;
|
||||
clear_platform_session(identity_generation, revision);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -1991,6 +2009,8 @@ pub(crate) fn write_game_creator_app_config(
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
let (current, overlays) = load_game_creator_app_config_for_write()?;
|
||||
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
|
||||
config.llm.custom_enabled = current.llm.custom_enabled;
|
||||
config.selected_model_id = current.selected_model_id;
|
||||
config.selected_model_is_default = current.selected_model_is_default;
|
||||
persist_game_creator_app_config(config, overlays, false)
|
||||
@@ -2027,7 +2047,12 @@ pub(crate) fn select_game_creator_model(
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
if model_id.is_empty()
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
if config.llm.custom_enabled {
|
||||
if !config.llm.visible_models.contains(&model_id) {
|
||||
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
|
||||
}
|
||||
} else if model_id.is_empty()
|
||||
|| model_id.len() > 64
|
||||
|| !model_id
|
||||
.bytes()
|
||||
@@ -2035,12 +2060,21 @@ pub(crate) fn select_game_creator_model(
|
||||
{
|
||||
return Err("模型标识无效".into());
|
||||
}
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
config.selected_model_id = model_id;
|
||||
config.selected_model_is_default = is_default;
|
||||
persist_game_creator_app_config(config, overlays, true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn discover_game_creator_llm_models(
|
||||
llm: GameCreatorLlmConfig,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !load_game_creator_app_config()?.llm.custom_enabled {
|
||||
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
|
||||
}
|
||||
fetch_custom_llm_models(&llm).await
|
||||
}
|
||||
|
||||
fn persist_game_creator_app_config(
|
||||
config: GameCreatorAppConfig,
|
||||
overlays: Vec<(PathBuf, serde_json::Value)>,
|
||||
@@ -2056,8 +2090,8 @@ fn persist_game_creator_app_config(
|
||||
let previous = overlay.clone();
|
||||
if let Some(fields) = overlay.as_object_mut() {
|
||||
for (key, value) in fields.iter_mut() {
|
||||
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
== model_only
|
||||
if !model_only
|
||||
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
{
|
||||
if let Some(saved_value) = saved.get(key) {
|
||||
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
|
||||
@@ -4231,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
|
||||
access.validate_frozen_session()?;
|
||||
let _platform_session_lease = frozen_session
|
||||
.as_ref()
|
||||
.map(|session| {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
|
||||
)
|
||||
})
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
access.validate_frozen_session()?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
custom_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
visible_models: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfig {
|
||||
#[serde(default)]
|
||||
custom_enabled: bool,
|
||||
#[serde(default)]
|
||||
visible_models: Vec<String>,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
|
||||
impl Default for GameCreatorLlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: String::new(),
|
||||
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
|
||||
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
|
||||
@@ -2209,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent)
|
||||
enum GameCreatorGuiRunnerShutdownOutcome {
|
||||
NotRequested,
|
||||
Requested,
|
||||
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
|
||||
Retained,
|
||||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||||
}
|
||||
|
||||
@@ -2246,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error(
|
||||
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
||||
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
||||
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
||||
} else if error.contains("实例锁") || error.contains("owner 锁") {
|
||||
} else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁")
|
||||
{
|
||||
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
||||
} else if error.contains("endpoint") {
|
||||
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
||||
@@ -2262,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown<F>(
|
||||
shutdown: F,
|
||||
) -> GameCreatorGuiRunnerShutdownOutcome
|
||||
where
|
||||
F: FnOnce() -> Result<(), String>,
|
||||
F: FnOnce() -> Result<bool, String>,
|
||||
{
|
||||
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
||||
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
||||
}
|
||||
match shutdown() {
|
||||
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||
Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained,
|
||||
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
||||
classify_game_creator_gui_runner_shutdown_error(&error),
|
||||
),
|
||||
@@ -2288,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
}
|
||||
}
|
||||
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
|
||||
match resolve_game_creator_gui_runner_shutdown(
|
||||
event,
|
||||
shutdown_external_agent_runner_for_gui_exit,
|
||||
) {
|
||||
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_requested")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Retained => {
|
||||
app_log!("agent.runner.gui_exit.retained_for_other_windows")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||||
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||
}
|
||||
@@ -2601,27 +2621,25 @@ fn main() {
|
||||
)
|
||||
})?;
|
||||
setup_log.append("startup.runner.configure.complete");
|
||||
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
||||
hold_external_agent_runner_gui_participant_lock(&config_dir)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
"startup.runner.owner-lock.failed details={details}"
|
||||
"startup.runner.participant-lock.failed details={details}"
|
||||
));
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
format!("获取 GUI owner 锁失败:{error}"),
|
||||
format!("建立 AGC 界面参与锁失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||||
app.manage(gui_owner_lock);
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink)
|
||||
.inspect_err(|error| {
|
||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
setup_log.fail(&format!(
|
||||
@@ -2715,12 +2733,13 @@ fn main() {
|
||||
confirm_resume_game_creator_agent_runtime_tasks,
|
||||
schedule_game_creator_agent_ready_tasks,
|
||||
check_game_creator_llm_config,
|
||||
read_platform_account_session_generation,
|
||||
read_platform_account_session_state,
|
||||
install_platform_account_session,
|
||||
clear_platform_account_session,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
select_game_creator_model,
|
||||
discover_game_creator_llm_models,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> {
|
||||
}
|
||||
|
||||
/// Call before and after every awaited remote action and immediately before installing a
|
||||
/// binding. Developer-key mode has no process-global account generation to compare.
|
||||
/// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让
|
||||
/// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然
|
||||
/// 失败关闭。Developer-key 模式没有进程级身份代次可比对。
|
||||
pub(crate) fn validate_frozen_session(&self) -> Result<(), String> {
|
||||
validate_external_editor_binding_access_shape(self)?;
|
||||
if let Some(session) = self.frozen_platform_session {
|
||||
validate_platform_session_snapshot(session)?;
|
||||
validate_frozen_platform_session(session)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1152,7 +1154,8 @@ mod tests {
|
||||
user_id: user_id.to_string(),
|
||||
access_token: token.to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
generation,
|
||||
identity_generation: generation,
|
||||
revision: generation,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session<T>(
|
||||
let Some(platform_session) = platform_session else {
|
||||
return action();
|
||||
};
|
||||
let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes());
|
||||
with_validated_platform_session_fingerprint(
|
||||
&platform_session.user_id,
|
||||
&platform_session.api_base_url,
|
||||
platform_session.generation,
|
||||
&access_token_sha256,
|
||||
action,
|
||||
)
|
||||
with_validated_platform_session_identity(&platform_session.identity(), action)
|
||||
}
|
||||
|
||||
fn commit_resource_edit_asset_with_frozen_platform_session(
|
||||
@@ -4774,14 +4767,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at(
|
||||
let current_platform_session = current_platform_session();
|
||||
let _platform_session_lease = current_platform_session
|
||||
.as_ref()
|
||||
.map(|session| {
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&sha256_hex(session.access_token.as_bytes()),
|
||||
)
|
||||
})
|
||||
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
|
||||
.transpose()?;
|
||||
let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?;
|
||||
let entries = match fs::read_dir(&directory) {
|
||||
@@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at(
|
||||
Ok(())
|
||||
};
|
||||
if let Some(session) = platform_session {
|
||||
let access_token_sha256 = sha256_hex(session.access_token.as_bytes());
|
||||
crate::platform_session::with_validated_platform_session_fingerprint(
|
||||
&session.user_id,
|
||||
&session.api_base_url,
|
||||
session.generation,
|
||||
&access_token_sha256,
|
||||
crate::platform_session::with_validated_platform_session_identity(
|
||||
&session.identity(),
|
||||
archive,
|
||||
)?;
|
||||
} else {
|
||||
@@ -5500,7 +5482,8 @@ mod tests {
|
||||
user_id: "gui-owner".to_string(),
|
||||
access_token: "gui-token".to_string(),
|
||||
api_base_url: "https://dev.genarrative.world".to_string(),
|
||||
generation: 7,
|
||||
identity_generation: 7,
|
||||
revision: 7,
|
||||
};
|
||||
let developer_credentials = (
|
||||
"https://dev.genarrative.world".to_string(),
|
||||
@@ -5911,6 +5894,7 @@ mod tests {
|
||||
"source-binding-token-b",
|
||||
api_base_url,
|
||||
*generation,
|
||||
*generation,
|
||||
)
|
||||
.expect("switch account after source registration");
|
||||
}
|
||||
@@ -6925,7 +6909,7 @@ mod tests {
|
||||
listener,
|
||||
upload_url,
|
||||
false,
|
||||
Some((base_url.clone(), frozen_session.generation + 1)),
|
||||
Some((base_url.clone(), frozen_session.identity_generation + 1)),
|
||||
done_receiver,
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
@@ -6952,7 +6936,8 @@ mod tests {
|
||||
"source-binding-owner-a",
|
||||
"source-binding-token-a",
|
||||
&base_url,
|
||||
frozen_session.generation + 2,
|
||||
frozen_session.identity_generation + 2,
|
||||
frozen_session.identity_generation + 2,
|
||||
)
|
||||
.expect("switch back to source binding owner A");
|
||||
let resumed_session = current_platform_session().expect("resumed source binding owner A");
|
||||
@@ -7018,7 +7003,7 @@ mod tests {
|
||||
install_test_platform_session("submission-owner-a", "submission-token-a", &base_url);
|
||||
let frozen_session = current_platform_session().expect("frozen owner A session");
|
||||
let switch_base_url = base_url.clone();
|
||||
let switch_generation = frozen_session.generation + 1;
|
||||
let switch_generation = frozen_session.identity_generation + 1;
|
||||
let server = std::thread::spawn(move || {
|
||||
let mut stream =
|
||||
accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0);
|
||||
@@ -7032,6 +7017,7 @@ mod tests {
|
||||
"submission-token-b",
|
||||
&switch_base_url,
|
||||
switch_generation,
|
||||
switch_generation,
|
||||
)
|
||||
.expect("switch to owner B before returning accepted response");
|
||||
write_json(
|
||||
@@ -7447,8 +7433,14 @@ mod tests {
|
||||
ledger.access_scheme = None;
|
||||
initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a))
|
||||
.expect("write resource ledger for owner A");
|
||||
replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2)
|
||||
.expect("switch global resource session to owner B");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"resource-owner-b",
|
||||
"resource-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global resource session to owner B");
|
||||
|
||||
let error = prepare_resource_edit_service_identity(
|
||||
root,
|
||||
@@ -7509,7 +7501,8 @@ mod tests {
|
||||
user_id: "resource-identity-owner-b".to_string(),
|
||||
access_token: "resource-identity-token-b".to_string(),
|
||||
api_base_url: owner_a.api_base_url.clone(),
|
||||
generation: owner_a.generation + 1,
|
||||
identity_generation: owner_a.identity_generation + 1,
|
||||
revision: owner_a.revision + 1,
|
||||
};
|
||||
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
|
||||
ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string());
|
||||
@@ -7535,7 +7528,8 @@ mod tests {
|
||||
&owner_b.user_id,
|
||||
&owner_b.access_token,
|
||||
&owner_b.api_base_url,
|
||||
owner_b.generation,
|
||||
owner_b.identity_generation,
|
||||
owner_b.revision,
|
||||
)
|
||||
.expect("switch to resource non-owner B");
|
||||
|
||||
@@ -7639,7 +7633,8 @@ mod tests {
|
||||
"resource-lease-owner-b",
|
||||
"resource-lease-token-b",
|
||||
api_base_url,
|
||||
frozen_a.generation + 1,
|
||||
frozen_a.identity_generation + 1,
|
||||
frozen_a.revision + 1,
|
||||
)
|
||||
.expect("switch resource lease owner");
|
||||
switched_sender.send(()).expect("signal resource switch");
|
||||
@@ -8259,7 +8254,8 @@ mod tests {
|
||||
"archive-owner-b",
|
||||
"archive-token-b",
|
||||
api_base_url,
|
||||
owner_a.generation + 1,
|
||||
owner_a.identity_generation + 1,
|
||||
owner_a.revision + 1,
|
||||
)
|
||||
.expect("switch to owner B");
|
||||
let error = archive_failed_local_project_resource_edit_at(
|
||||
@@ -8388,7 +8384,8 @@ mod tests {
|
||||
"pending-owner-b",
|
||||
"pending-token-b",
|
||||
api_base_url,
|
||||
owner_a.generation + 1,
|
||||
owner_a.identity_generation + 1,
|
||||
owner_a.revision + 1,
|
||||
)
|
||||
.expect("switch to pending owner B");
|
||||
let owner_b = current_platform_session().expect("pending owner B session");
|
||||
@@ -9347,7 +9344,7 @@ mod tests {
|
||||
let (attempted_sender, attempted_receiver) = mpsc::channel();
|
||||
let (completed_sender, completed_receiver) = mpsc::channel();
|
||||
let switch_api_base_url = api_base_url.to_string();
|
||||
let switch_generation = frozen_session.generation + 1;
|
||||
let switch_generation = frozen_session.identity_generation + 1;
|
||||
let switch_thread = std::thread::spawn(move || {
|
||||
begin_switch_receiver
|
||||
.recv()
|
||||
@@ -9360,6 +9357,7 @@ mod tests {
|
||||
"commit-token-b",
|
||||
&switch_api_base_url,
|
||||
switch_generation,
|
||||
switch_generation,
|
||||
)
|
||||
.expect("switch to commit owner B");
|
||||
completed_sender
|
||||
|
||||
@@ -12,21 +12,20 @@ pub(crate) use client::{
|
||||
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
|
||||
configure_external_agent_runner, configure_external_agent_runner_read_only,
|
||||
continue_external_agent_runner_action, ensure_external_agent_runner_started,
|
||||
ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session,
|
||||
ensure_external_agent_runner_started_for_gui, hold_external_agent_runner_gui_participant_lock,
|
||||
install_external_agent_runner_platform_session,
|
||||
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
||||
pause_external_agent_runner, read_external_agent_runner_status,
|
||||
require_external_agent_runner_configured_for_cli_runtime_write,
|
||||
require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner,
|
||||
shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit,
|
||||
shutdown_external_agent_runner_if_idle, steer_external_agent_runner,
|
||||
wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run,
|
||||
shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle,
|
||||
steer_external_agent_runner, wake_external_agent_runner_pending,
|
||||
wake_external_agent_runner_pending_for_run,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
pub(crate) use endpoint::validate_windows_regular_file_handle;
|
||||
pub(crate) use endpoint::{
|
||||
acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled,
|
||||
external_agent_runner_is_server_process,
|
||||
};
|
||||
pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION};
|
||||
pub(crate) use server::{
|
||||
|
||||
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