Merge remote-tracking branch 'origin/master'
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,9 @@ pub(crate) use canvas_generation::{
|
||||
external_generation_result_has_download_reference,
|
||||
external_generation_submit_rejection_is_definitive,
|
||||
platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context,
|
||||
submit_external_generation_request, wait_for_external_generation_result,
|
||||
ExternalCanvasGenerationContext, ExternalGenerationInitialResponse,
|
||||
resolve_canvas_resource_download_with_access, submit_external_generation_request,
|
||||
wait_for_external_generation_result_with_access, ExternalCanvasGenerationContext,
|
||||
ExternalGenerationInitialResponse,
|
||||
};
|
||||
pub(in crate::agent) use canvas_generation::{
|
||||
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+516
-92
File diff suppressed because it is too large
Load Diff
@@ -262,10 +262,11 @@ pub(crate) use entrypoints::{
|
||||
chat_with_game_creator_role_agent_stream_for_session_at,
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
|
||||
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
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, set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
|
||||
|
||||
@@ -145,6 +145,15 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
||||
port: u16,
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
|
||||
install_game_creator_manifest_invalidation_event_sink(sink);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
|
||||
port: u16,
|
||||
token: &str,
|
||||
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
|
||||
if port == 0 {
|
||||
return Err("manifest 失效事件接收端口无效".to_string());
|
||||
}
|
||||
@@ -152,12 +161,16 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
||||
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("manifest 失效事件接收令牌无效".to_string());
|
||||
}
|
||||
*lock_game_creator_manifest_invalidation_event_sink() =
|
||||
Some(GameCreatorManifestInvalidationEventSink {
|
||||
port,
|
||||
token: token.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
Ok(GameCreatorManifestInvalidationEventSink {
|
||||
port,
|
||||
token: token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
|
||||
sink: GameCreatorManifestInvalidationEventSink,
|
||||
) {
|
||||
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -444,12 +444,10 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let generation_directory = root.join(".agent/runtime/canvas-generation-requests");
|
||||
if durable_agent_runtime_recovery_directory_has_entries(&generation_directory)
|
||||
&& !generation_directory_only_contains_retained_direct_taonier_states_at(root)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(true);
|
||||
match collect_agent_owned_platform_art_generation_runtime_identities_at(root) {
|
||||
Ok(identities) if !identities.is_empty() => return Ok(true),
|
||||
Err(_) => return Ok(true),
|
||||
Ok(_) => {}
|
||||
}
|
||||
if durable_process_session_recovery_exists_at(root) {
|
||||
return Ok(true);
|
||||
@@ -490,87 +488,211 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo
|
||||
false
|
||||
}
|
||||
|
||||
fn generation_directory_only_contains_retained_direct_taonier_states_at(
|
||||
root: &Path,
|
||||
) -> Result<bool, String> {
|
||||
let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?;
|
||||
let agent_entries = match fs::read_dir(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(format!("读取 External Editor 生成账本目录失败:{error}")),
|
||||
};
|
||||
let expected_agent_component =
|
||||
agent_runtime_confirmation_path_component("direct-codex-art", "agent");
|
||||
let mut found = false;
|
||||
for agent_entry in agent_entries {
|
||||
let agent_entry = agent_entry.map_err(|error| format!("遍历生成账本目录失败:{error}"))?;
|
||||
let metadata = fs::symlink_metadata(agent_entry.path())
|
||||
.map_err(|error| format!("读取生成账本目录元数据失败:{error}"))?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| !metadata.is_dir()
|
||||
|| agent_entry.file_name().to_str() != Some(expected_agent_component.as_str())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
for entry in fs::read_dir(agent_entry.path())
|
||||
.map_err(|error| format!("读取 Direct 生成账本目录失败:{error}"))?
|
||||
{
|
||||
let entry = entry.map_err(|error| format!("遍历 Direct 生成账本失败:{error}"))?;
|
||||
let metadata = fs::symlink_metadata(entry.path())
|
||||
.map_err(|error| format!("读取 Direct 生成账本元数据失败:{error}"))?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| !metadata.is_file()
|
||||
|| metadata.len() > 256 * 1024
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let payload = fs::read(entry.path())
|
||||
.map_err(|error| format!("读取 Direct 生成账本失败:{error}"))?;
|
||||
let payload = serde_json::from_slice::<serde_json::Value>(&payload)
|
||||
.map_err(|error| format!("解析 Direct 生成账本失败:{error}"))?;
|
||||
if payload
|
||||
.get("schemaVersion")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
!= Some(PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION)
|
||||
|| payload.get("agentId").and_then(serde_json::Value::as_str)
|
||||
!= Some("direct-codex-art")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(run_id) = payload.get("runId").and_then(serde_json::Value::as_str) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let expected_file_name = format!(
|
||||
"{}.json",
|
||||
agent_runtime_confirmation_path_component(run_id, "run")
|
||||
);
|
||||
let expected_backup_name = format!(".{expected_file_name}.previous");
|
||||
let Some(file_name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if file_name != expected_file_name && file_name != expected_backup_name {
|
||||
return Ok(false);
|
||||
}
|
||||
if !direct_taonier_regeneration_workflow_retains_stage_ledger_at(
|
||||
root,
|
||||
"direct-codex-art",
|
||||
run_id,
|
||||
)? {
|
||||
return Ok(false);
|
||||
}
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
fn lowercase_sha256(value: Option<&str>) -> bool {
|
||||
value.is_some_and(|value| {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup_orphaned_platform_art_generation_runtime_states_at(
|
||||
fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value) -> bool {
|
||||
const ALLOWED_FIELDS: &[&str] = &[
|
||||
"schemaVersion",
|
||||
"projectId",
|
||||
"agentId",
|
||||
"taskId",
|
||||
"sessionId",
|
||||
"runId",
|
||||
"source",
|
||||
"actionId",
|
||||
"actionFingerprint",
|
||||
"externalConfigurationFingerprint",
|
||||
"accessScheme",
|
||||
"externalServiceOrigin",
|
||||
"platformOwnerUserId",
|
||||
"endpoint",
|
||||
"canvasName",
|
||||
"generationPrompt",
|
||||
"requestBodySha256",
|
||||
"requestBodyJson",
|
||||
"idempotencyKey",
|
||||
"status",
|
||||
"operationId",
|
||||
"pollAfterMs",
|
||||
"legacyResult",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
];
|
||||
if !payload.as_object().is_some_and(|object| {
|
||||
object
|
||||
.keys()
|
||||
.all(|field| ALLOWED_FIELDS.contains(&field.as_str()))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let Some(request_body_json) = payload
|
||||
.get("requestBodyJson")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let expected_sha256 = format!("{:x}", Sha256::digest(request_body_json.as_bytes()));
|
||||
if payload
|
||||
.get("requestBodySha256")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
!= Some(expected_sha256.as_str())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(request_body) = serde_json::from_str::<serde_json::Value>(request_body_json) else {
|
||||
return false;
|
||||
};
|
||||
let endpoint = payload.get("endpoint").and_then(serde_json::Value::as_str);
|
||||
let endpoint_is_valid = matches!(
|
||||
endpoint,
|
||||
Some(
|
||||
"/api/external/v1/editor/images/generations"
|
||||
| "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
)
|
||||
);
|
||||
endpoint_is_valid
|
||||
&& payload
|
||||
.get("canvasName")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& payload
|
||||
.get("generationPrompt")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& json_string_field(&request_body, "projectId")
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& json_string_field(&request_body, "assetFolderId")
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn standalone_platform_art_generation_ledger_identity_is_valid(
|
||||
payload: &serde_json::Value,
|
||||
project_id: &str,
|
||||
) -> bool {
|
||||
let string_field = |name| payload.get(name).and_then(serde_json::Value::as_str);
|
||||
let principal_is_valid = match string_field("schemaVersion") {
|
||||
Some(PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION) => {
|
||||
let Some(service_origin) = string_field("externalServiceOrigin") else {
|
||||
return false;
|
||||
};
|
||||
let origin_is_canonical =
|
||||
platform_art_generation_external_service_origin(service_origin)
|
||||
.is_ok_and(|normalized| normalized == service_origin);
|
||||
let access_is_valid = match string_field("accessScheme") {
|
||||
Some("official-platform-v1") => string_field("platformOwnerUserId")
|
||||
.is_some_and(|owner| !owner.trim().is_empty() && owner.len() <= 256),
|
||||
Some("external-developer-v1") => payload
|
||||
.get("platformOwnerUserId")
|
||||
.is_none_or(serde_json::Value::is_null),
|
||||
_ => false,
|
||||
};
|
||||
origin_is_canonical && access_is_valid
|
||||
}
|
||||
Some("agent-runtime-canvas-generation-request.v2") => {
|
||||
!payload.as_object().is_some_and(|object| {
|
||||
object.contains_key("accessScheme")
|
||||
|| object.contains_key("externalServiceOrigin")
|
||||
|| object.contains_key("platformOwnerUserId")
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if string_field("projectId") != Some(project_id)
|
||||
|| !lowercase_sha256(string_field("externalConfigurationFingerprint"))
|
||||
|| !lowercase_sha256(string_field("requestBodySha256"))
|
||||
|| !platform_art_generation_request_snapshot_is_valid(payload)
|
||||
|| !principal_is_valid
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(agent_id) = string_field("agentId") else {
|
||||
return false;
|
||||
};
|
||||
let Some(run_id) = string_field("runId") else {
|
||||
return false;
|
||||
};
|
||||
let manual_identity_is_valid = if matches!(
|
||||
agent_id,
|
||||
"manual-canvas-asset-generate" | "manual-canvas-asset-generate-strict"
|
||||
) {
|
||||
let identity = format!("{agent_id}:{run_id}");
|
||||
let current_identity_is_valid = run_id
|
||||
.strip_prefix("slot-")
|
||||
.is_some_and(|value| lowercase_sha256(Some(value)))
|
||||
&& string_field("taskId") == Some(identity.as_str())
|
||||
&& string_field("sessionId") == Some(identity.as_str())
|
||||
&& string_field("source") == Some("tauri-command")
|
||||
&& string_field("actionId") == Some(identity.as_str())
|
||||
&& lowercase_sha256(string_field("actionFingerprint"));
|
||||
let legacy_identity_is_valid = run_id == agent_id
|
||||
&& string_field("taskId") == Some(agent_id)
|
||||
&& string_field("sessionId") == Some(agent_id)
|
||||
&& string_field("source") == Some("tauri-command")
|
||||
&& string_field("actionId") == Some(agent_id)
|
||||
&& string_field("actionFingerprint") == Some(agent_id);
|
||||
current_identity_is_valid || legacy_identity_is_valid
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let direct_identity_is_valid = if agent_id == "direct-codex-art" {
|
||||
let expected_asset_kind = match run_id {
|
||||
"art-spec" => Some("icon-spec"),
|
||||
"game-background" => Some("game-background"),
|
||||
"art-spritesheet" => Some("art-spritesheet"),
|
||||
_ => None,
|
||||
};
|
||||
expected_asset_kind.is_some_and(|asset_kind| {
|
||||
string_field("taskId") == Some(format!("direct-codex-art-{run_id}").as_str())
|
||||
&& string_field("sessionId") == Some(project_id)
|
||||
&& string_field("source") == Some("direct-codex")
|
||||
&& string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str())
|
||||
&& string_field("actionFingerprint")
|
||||
== Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str())
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let idempotency_key_is_valid = string_field("idempotencyKey").is_some_and(|value| {
|
||||
!value.is_empty() && value.len() <= 128 && value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
});
|
||||
let status_is_valid = match string_field("status") {
|
||||
Some("prepared") => true,
|
||||
Some("accepted") => string_field("operationId").is_some_and(|value| !value.is_empty()),
|
||||
Some("legacy-completed") => payload
|
||||
.get("legacyResult")
|
||||
.is_some_and(|result| !result.is_null()),
|
||||
_ => false,
|
||||
};
|
||||
(manual_identity_is_valid || direct_identity_is_valid)
|
||||
&& idempotency_key_is_valid
|
||||
&& status_is_valid
|
||||
&& payload
|
||||
.get("createdAt")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|created| {
|
||||
created > 0
|
||||
&& payload
|
||||
.get("updatedAt")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|updated| updated >= created)
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_agent_owned_platform_art_generation_runtime_identities_at(
|
||||
root: &Path,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<std::collections::BTreeSet<(String, String)>, String> {
|
||||
let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?;
|
||||
let project_id = game_creator_agent_runtime_context_project_id(root)?;
|
||||
let agent_entries = match fs::read_dir(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 External Editor 生成账本目录失败:{}: {error}",
|
||||
@@ -645,7 +767,11 @@ fn cleanup_orphaned_platform_art_generation_runtime_states_at(
|
||||
.get("schemaVersion")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("(missing)");
|
||||
if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION {
|
||||
if !matches!(
|
||||
schema_version,
|
||||
PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION
|
||||
| "agent-runtime-canvas-generation-request.v2"
|
||||
) {
|
||||
return Err(format!(
|
||||
"External Editor 生成账本版本无效:{file_name}: {schema_version}"
|
||||
));
|
||||
@@ -672,10 +798,38 @@ fn cleanup_orphaned_platform_art_generation_runtime_states_at(
|
||||
{
|
||||
return Err("External Editor 生成账本路径与内部身份不一致".to_string());
|
||||
}
|
||||
if standalone_platform_art_generation_ledger_identity_is_valid(&payload, &project_id) {
|
||||
if agent_id != "direct-codex-art"
|
||||
|| direct_taonier_regeneration_workflow_retains_stage_ledger_at(
|
||||
root, agent_id, run_id,
|
||||
)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A valid Direct stage identity without the exact v4 workflow owner is still
|
||||
// recoverable Agent work. Keep it visible so cleanup fails closed rather than
|
||||
// silently treating a paid generation as a standalone manual request.
|
||||
identities.insert((agent_id.to_string(), run_id.to_string()));
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
agent_id,
|
||||
"manual-canvas-asset-generate"
|
||||
| "manual-canvas-asset-generate-strict"
|
||||
| "direct-codex-art"
|
||||
) {
|
||||
return Err("standalone External Editor 生成账本身份无效".to_string());
|
||||
}
|
||||
identities.insert((agent_id.to_string(), run_id.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
fn cleanup_orphaned_platform_art_generation_runtime_states_at(
|
||||
root: &Path,
|
||||
) -> Result<usize, String> {
|
||||
let identities = collect_agent_owned_platform_art_generation_runtime_identities_at(root)?;
|
||||
let mut removed = 0_usize;
|
||||
for (agent_id, run_id) in identities {
|
||||
if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) {
|
||||
@@ -1465,7 +1619,10 @@ mod orphaned_external_generation_recovery_tests {
|
||||
"source": "direct-codex",
|
||||
"actionId": "direct-taonier-art-spec",
|
||||
"actionFingerprint": "direct-taonier-art-v1:icon-spec:art-spec",
|
||||
"externalConfigurationFingerprint": "test-service",
|
||||
"externalConfigurationFingerprint": "c".repeat(64),
|
||||
"accessScheme": "external-developer-v1",
|
||||
"externalServiceOrigin": "https://editor.example.test",
|
||||
"platformOwnerUserId": null,
|
||||
"endpoint": "/api/external/v1/editor/images/generations",
|
||||
"canvasName": "Direct TaoNier",
|
||||
"generationPrompt": "Direct art spec",
|
||||
@@ -1533,6 +1690,108 @@ mod orphaned_external_generation_recovery_tests {
|
||||
assert!(stage_path.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_generation_ledgers_do_not_trigger_agent_recovery_or_orphan_cleanup() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("standalone-generation-scan-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "standalone-generation-scan", "手工生成账本扫描测试")
|
||||
.expect("init standalone scan project");
|
||||
let project_id = game_creator_agent_runtime_context_project_id(root)
|
||||
.expect("read standalone scan project id");
|
||||
let mut persisted_paths = Vec::new();
|
||||
for (index, agent_id) in [
|
||||
"manual-canvas-asset-generate",
|
||||
"manual-canvas-asset-generate-strict",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let run_id = format!("slot-{}", if index == 0 { "a" } else { "b" }.repeat(64));
|
||||
let identity = format!("{agent_id}:{run_id}");
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "standalone scanner fixture",
|
||||
"kind": "spec",
|
||||
"projectId": "standalone-remote-project",
|
||||
"assetFolderId": "standalone-remote-folder",
|
||||
"referenceImageSrcs": []
|
||||
});
|
||||
let request_body_json =
|
||||
serde_json::to_string(&request_body).expect("serialize standalone request body");
|
||||
let relative_path = format!(
|
||||
".agent/runtime/canvas-generation-requests/{}/{}.json",
|
||||
agent_runtime_confirmation_path_component(agent_id, "agent"),
|
||||
agent_runtime_confirmation_path_component(&run_id, "run")
|
||||
);
|
||||
let mut ledger = serde_json::json!({
|
||||
"schemaVersion": PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION,
|
||||
"projectId": project_id,
|
||||
"agentId": agent_id,
|
||||
"taskId": identity,
|
||||
"sessionId": identity,
|
||||
"runId": run_id,
|
||||
"source": "tauri-command",
|
||||
"actionId": identity,
|
||||
"actionFingerprint": "c".repeat(64),
|
||||
"externalConfigurationFingerprint": "d".repeat(64),
|
||||
"accessScheme": "external-developer-v1",
|
||||
"externalServiceOrigin": "https://editor.example.test",
|
||||
"platformOwnerUserId": null,
|
||||
"endpoint": "/api/external/v1/editor/images/generations",
|
||||
"canvasName": "standalone scanner canvas",
|
||||
"generationPrompt": "standalone scanner fixture",
|
||||
"requestBodySha256": format!("{:x}", Sha256::digest(request_body_json.as_bytes())),
|
||||
"requestBodyJson": request_body_json,
|
||||
"idempotencyKey": format!("standalone-scanner-{index}"),
|
||||
"status": "prepared",
|
||||
"createdAt": 1,
|
||||
"updatedAt": 1
|
||||
});
|
||||
if index == 1 {
|
||||
let object = ledger
|
||||
.as_object_mut()
|
||||
.expect("standalone scanner fixture is an object");
|
||||
object.insert(
|
||||
"schemaVersion".to_string(),
|
||||
serde_json::Value::String(
|
||||
"agent-runtime-canvas-generation-request.v2".to_string(),
|
||||
),
|
||||
);
|
||||
object.remove("accessScheme");
|
||||
object.remove("externalServiceOrigin");
|
||||
object.remove("platformOwnerUserId");
|
||||
}
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
&relative_path,
|
||||
"standalone scanner fixture",
|
||||
&ledger,
|
||||
256 * 1024,
|
||||
)
|
||||
.expect("write standalone scanner ledger");
|
||||
let path = resolve_local_project_path(root, &relative_path)
|
||||
.expect("resolve standalone scanner ledger");
|
||||
persisted_paths.push((
|
||||
path.clone(),
|
||||
fs::read(path).expect("read standalone ledger"),
|
||||
));
|
||||
}
|
||||
|
||||
assert!(
|
||||
!has_recoverable_game_creator_agent_background_tasks_at(root)
|
||||
.expect("standalone ledgers must not look like Agent recovery work")
|
||||
);
|
||||
let resumed = resume_game_creator_agent_background_tasks_at(root)
|
||||
.expect("standalone ledgers must not block Agent recovery scan");
|
||||
assert!(resumed.is_empty());
|
||||
for (path, original) in persisted_paths {
|
||||
assert_eq!(
|
||||
fs::read(path).expect("standalone ledger remains"),
|
||||
original,
|
||||
"Agent orphan cleanup must preserve standalone ledger bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-");
|
||||
|
||||
@@ -2202,7 +2202,7 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait(
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<Option<File>, String> {
|
||||
@@ -2311,7 +2311,7 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<Option<File>, String> {
|
||||
@@ -2442,7 +2442,7 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<Option<File>, String> {
|
||||
|
||||
@@ -1065,7 +1065,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
_client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
bearer_token: &str,
|
||||
@@ -1073,6 +1073,30 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
max_bytes: usize,
|
||||
read_url_route: &str,
|
||||
) -> Result<Option<CanvasResourceDownload>, String> {
|
||||
resolve_canvas_resource_download_with_limit_route_and_fence(
|
||||
_client,
|
||||
api_base_url,
|
||||
bearer_token,
|
||||
resource,
|
||||
max_bytes,
|
||||
read_url_route,
|
||||
|| Ok(()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_canvas_resource_download_with_limit_route_and_fence<F>(
|
||||
_client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
bearer_token: &str,
|
||||
resource: &serde_json::Value,
|
||||
max_bytes: usize,
|
||||
read_url_route: &str,
|
||||
mut fence: F,
|
||||
) -> Result<Option<CanvasResourceDownload>, String>
|
||||
where
|
||||
F: FnMut() -> Result<(), String>,
|
||||
{
|
||||
if max_bytes == 0 {
|
||||
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
|
||||
}
|
||||
@@ -1091,10 +1115,11 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
api_base_url,
|
||||
percent_encode_query_component(object_key)
|
||||
);
|
||||
(
|
||||
Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?),
|
||||
true,
|
||||
)
|
||||
fence()?;
|
||||
let signed_url =
|
||||
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await;
|
||||
fence()?;
|
||||
(Some(signed_url?), true)
|
||||
} else if let Some(image_src) = image_src.as_deref() {
|
||||
if image_src.starts_with('/') {
|
||||
let read_url = format!(
|
||||
@@ -1102,13 +1127,11 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
api_base_url,
|
||||
percent_encode_query_component(image_src)
|
||||
);
|
||||
(
|
||||
Some(
|
||||
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url)
|
||||
.await?,
|
||||
),
|
||||
true,
|
||||
)
|
||||
fence()?;
|
||||
let signed_url =
|
||||
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await;
|
||||
fence()?;
|
||||
(Some(signed_url?), true)
|
||||
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
|
||||
(Some(image_src.to_string()), false)
|
||||
} else {
|
||||
@@ -1121,14 +1144,15 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
return Ok(None);
|
||||
};
|
||||
let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?;
|
||||
let download_client =
|
||||
build_external_asset_download_client(&url, api_base_url, came_from_stable_reference)
|
||||
.await?;
|
||||
let mut response = download_client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("下载画板资产失败:{error}"))?;
|
||||
fence()?;
|
||||
let download_client_result =
|
||||
build_external_asset_download_client(&url, api_base_url, came_from_stable_reference).await;
|
||||
fence()?;
|
||||
let download_client = download_client_result?;
|
||||
fence()?;
|
||||
let response_result = download_client.get(url).send().await;
|
||||
fence()?;
|
||||
let mut response = response_result.map_err(|error| format!("下载画板资产失败:{error}"))?;
|
||||
let status = response.status();
|
||||
if status.is_redirection() {
|
||||
return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string());
|
||||
@@ -1160,11 +1184,14 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
|
||||
.unwrap_or_default()
|
||||
.min(max_bytes),
|
||||
);
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("读取画板资产失败:{error}"))?
|
||||
{
|
||||
loop {
|
||||
fence()?;
|
||||
let chunk = response.chunk().await;
|
||||
fence()?;
|
||||
let Some(chunk) = chunk.map_err(|error| format!("读取画板资产失败:{error}"))?
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let next_len = bytes
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
@@ -1884,7 +1911,7 @@ mod tests {
|
||||
assert!(!external_editor_api_credentials_override_is_active());
|
||||
}
|
||||
|
||||
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
|
||||
fn read_asset_test_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set asset test read timeout");
|
||||
@@ -1897,6 +1924,7 @@ mod tests {
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2109,4 +2137,74 @@ mod tests {
|
||||
server.join().expect("join bounded fixture");
|
||||
assert!(error.contains("下载预算"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canvas_download_revalidates_session_after_signed_url_before_media_get() {
|
||||
let listener =
|
||||
std::net::TcpListener::bind("127.0.0.1:0").expect("bind fenced download fixture");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
|
||||
let signed_url = format!("{base_url}/signed.png");
|
||||
let (request_sender, request_receiver) = std::sync::mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut signing, _) = listener.accept().expect("accept signing request");
|
||||
request_sender
|
||||
.send(read_asset_test_request(&mut signing))
|
||||
.expect("capture signing request");
|
||||
let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string();
|
||||
write!(
|
||||
signing,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.expect("write signing response");
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set fenced fixture nonblocking");
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(200);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match listener.accept() {
|
||||
Ok((mut media, _)) => {
|
||||
request_sender
|
||||
.send(read_asset_test_request(&mut media))
|
||||
.expect("capture forbidden media request");
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(error) => panic!("accept fenced media request: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut fence_calls = 0_u8;
|
||||
let error = match resolve_canvas_resource_download_with_limit_route_and_fence(
|
||||
&reqwest::Client::new(),
|
||||
&base_url,
|
||||
"test-api-key",
|
||||
&serde_json::json!({"objectKey": "stable/slice.png"}),
|
||||
1024,
|
||||
"/api/external/v1/assets/read-url",
|
||||
|| {
|
||||
fence_calls = fence_calls.saturating_add(1);
|
||||
if fence_calls == 2 {
|
||||
Err("session-switched-after-read-url".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("session switch after signed URL must stop before media GET"),
|
||||
};
|
||||
server.join().expect("join fenced download fixture");
|
||||
|
||||
assert_eq!(error, "session-switched-after-read-url");
|
||||
let requests = request_receiver.try_iter().collect::<Vec<_>>();
|
||||
assert_eq!(requests.len(), 1, "media GET is forbidden after switch");
|
||||
assert!(requests[0].starts_with("GET /api/external/v1/assets/read-url?"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1511,6 +1511,11 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
||||
check_game_creator_llm_config_from_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||||
current_platform_session_generation()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn install_platform_account_session(
|
||||
user_id: String,
|
||||
@@ -1518,20 +1523,20 @@ pub(crate) fn install_platform_account_session(
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)?;
|
||||
let _ = install_external_agent_runner_platform_session(
|
||||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||||
install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
);
|
||||
Ok(())
|
||||
)?;
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
clear_external_agent_runner_platform_session(generation)?;
|
||||
clear_platform_session(generation);
|
||||
let _ = clear_external_agent_runner_platform_session(generation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -2217,32 +2217,15 @@ fn main() {
|
||||
format!("获取 GUI owner 锁失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||||
app.manage(gui_owner_lock);
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin");
|
||||
}
|
||||
ensure_external_agent_runner_started_for_gui()
|
||||
.inspect_err(|error| {
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let details =
|
||||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||
let _ = append_bounded_diagnostic_line(
|
||||
path,
|
||||
&format!("startup.runner.start.failed details={details}"),
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
}
|
||||
})
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("启动 Agent Runner 失败:{error}"),
|
||||
)
|
||||
})?;
|
||||
set_game_creator_agent_runtime_update_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)
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
.inspect_err(|error| {
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let details =
|
||||
@@ -2305,6 +2288,7 @@ 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,
|
||||
install_platform_account_session,
|
||||
clear_platform_account_session,
|
||||
read_game_creator_app_config,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -86,6 +87,27 @@ pub(crate) fn install_platform_session(
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let snapshot =
|
||||
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
install_platform_session_in(
|
||||
&mut current,
|
||||
&snapshot.user_id,
|
||||
&snapshot.access_token,
|
||||
&snapshot.api_base_url,
|
||||
snapshot.generation,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validated_platform_session_snapshot(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<PlatformSessionSnapshot, String> {
|
||||
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
|
||||
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
|
||||
}
|
||||
@@ -98,17 +120,62 @@ pub(crate) fn install_platform_session(
|
||||
if access_token.is_empty() || access_token.len() > 16 * 1024 {
|
||||
return Err("陶泥儿登录凭据无效".to_string());
|
||||
}
|
||||
Ok(PlatformSessionSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
api_base_url,
|
||||
generation,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_platform_session_input(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn replace_platform_session_for_gui_owner(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let snapshot =
|
||||
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
current.generation = snapshot.generation;
|
||||
current.snapshot = Some(snapshot);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn install_platform_session_checked(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let snapshot =
|
||||
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
install_platform_session_in(
|
||||
&mut current,
|
||||
user_id,
|
||||
access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
&snapshot.user_id,
|
||||
&snapshot.access_token,
|
||||
&snapshot.api_base_url,
|
||||
snapshot.generation,
|
||||
);
|
||||
Ok(())
|
||||
if current.snapshot.as_ref() == Some(&snapshot) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
||||
@@ -144,6 +211,26 @@ pub(crate) fn clear_platform_session(generation: u64) {
|
||||
clear_platform_session_in(&mut current, generation);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) {
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
current.generation = generation;
|
||||
current.snapshot = None;
|
||||
}
|
||||
|
||||
pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> {
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
clear_platform_session_in(&mut current, generation);
|
||||
if current.generation == generation && current.snapshot.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("authentication-required: 平台登出 generation 已过期".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
||||
platform_session()
|
||||
.lock()
|
||||
@@ -152,6 +239,13 @@ pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn current_platform_session_generation() -> u64 {
|
||||
platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.generation
|
||||
}
|
||||
|
||||
pub(crate) fn validate_platform_session_snapshot(
|
||||
expected: &PlatformSessionSnapshot,
|
||||
) -> Result<(), String> {
|
||||
@@ -165,6 +259,53 @@ pub(crate) fn validate_platform_session_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_validated_platform_session_fingerprint<T>(
|
||||
expected_user_id: &str,
|
||||
expected_api_base_url: &str,
|
||||
expected_generation: u64,
|
||||
expected_access_token_sha256: &str,
|
||||
action: impl FnOnce() -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let lease = acquire_validated_platform_session_fingerprint(
|
||||
expected_user_id,
|
||||
expected_api_base_url,
|
||||
expected_generation,
|
||||
expected_access_token_sha256,
|
||||
)?;
|
||||
let result = action();
|
||||
drop(lease);
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) struct ValidatedPlatformSessionLease {
|
||||
_guard: std::sync::MutexGuard<'static, PlatformSessionState>,
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_validated_platform_session_fingerprint(
|
||||
expected_user_id: &str,
|
||||
expected_api_base_url: &str,
|
||||
expected_generation: u64,
|
||||
expected_access_token_sha256: &str,
|
||||
) -> Result<ValidatedPlatformSessionLease, String> {
|
||||
let current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let matches = current.snapshot.as_ref().is_some_and(|snapshot| {
|
||||
snapshot.user_id == expected_user_id
|
||||
&& snapshot.api_base_url == expected_api_base_url
|
||||
&& snapshot.generation == expected_generation
|
||||
&& format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes()))
|
||||
== expected_access_token_sha256
|
||||
});
|
||||
if !matches {
|
||||
return Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(ValidatedPlatformSessionLease { _guard: current })
|
||||
}
|
||||
|
||||
fn platform_session_snapshot_matches(
|
||||
current: Option<&PlatformSessionSnapshot>,
|
||||
expected: &PlatformSessionSnapshot,
|
||||
@@ -237,6 +378,27 @@ pub(crate) fn install_test_platform_session(
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the shared test-session lock while presenting an explicit logged-out
|
||||
/// state. Tests that assert missing-login behavior must use this guard so they
|
||||
/// cannot observe a platform session installed by another parallel test.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard {
|
||||
let isolation = PLATFORM_SESSION_TEST_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let previous = std::mem::take(&mut *current);
|
||||
*current = PlatformSessionState::default();
|
||||
drop(current);
|
||||
TestPlatformSessionGuard {
|
||||
_isolation: isolation,
|
||||
previous: Some(previous),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -321,6 +483,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_generation_preserves_the_floor_after_session_clear() {
|
||||
let _session = clear_test_platform_session();
|
||||
install_platform_session(
|
||||
"generation-floor-user",
|
||||
"generation-floor-token",
|
||||
"https://dev.genarrative.world",
|
||||
41,
|
||||
)
|
||||
.expect("install session generation floor");
|
||||
clear_platform_session(42);
|
||||
|
||||
assert_eq!(current_platform_session_generation(), 42);
|
||||
assert!(current_platform_session().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
|
||||
assert_eq!(
|
||||
@@ -386,4 +564,53 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_session_lease_linearizes_local_commit_with_account_switch() {
|
||||
let _session = install_test_platform_session(
|
||||
"lease-user-a",
|
||||
"lease-token-a",
|
||||
"https://dev.genarrative.world",
|
||||
);
|
||||
let expected = current_platform_session().expect("current lease session");
|
||||
let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes()));
|
||||
let lease = acquire_validated_platform_session_fingerprint(
|
||||
&expected.user_id,
|
||||
&expected.api_base_url,
|
||||
expected.generation,
|
||||
&token_sha256,
|
||||
)
|
||||
.expect("acquire validated session lease");
|
||||
let (started_sender, started_receiver) = std::sync::mpsc::channel();
|
||||
let (finished_sender, finished_receiver) = std::sync::mpsc::channel();
|
||||
let switcher = std::thread::spawn(move || {
|
||||
started_sender.send(()).expect("signal account switch");
|
||||
install_platform_session(
|
||||
"lease-user-b",
|
||||
"lease-token-b",
|
||||
"https://dev.genarrative.world",
|
||||
2,
|
||||
)
|
||||
.expect("switch account after lease release");
|
||||
finished_sender.send(()).expect("signal switched account");
|
||||
});
|
||||
started_receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("switcher started");
|
||||
assert!(
|
||||
finished_receiver
|
||||
.recv_timeout(std::time::Duration::from_millis(100))
|
||||
.is_err(),
|
||||
"account switch must wait until the synchronous local commit lease is released"
|
||||
);
|
||||
drop(lease);
|
||||
finished_receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("account switch completed after lease release");
|
||||
switcher.join().expect("join account switcher");
|
||||
assert_eq!(
|
||||
current_platform_session().map(|session| session.user_id),
|
||||
Some("lease-user-b".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ mod asset_canvas;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
mod external_editor_bindings;
|
||||
mod filesystem;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
@@ -21,6 +22,7 @@ pub(crate) use asset_canvas::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
pub(crate) use external_editor_bindings::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
|
||||
@@ -984,6 +984,27 @@ fn take_agent_db_record_failure_injection(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_agent_db_record_after_sync_failure_injection(
|
||||
root: &Path,
|
||||
record_type: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record-after-sync");
|
||||
match fs::read_to_string(&failure_path) {
|
||||
Ok(expected_record_type) if record_type == Some(expected_record_type.trim()) => {
|
||||
fs::remove_file(&failure_path)
|
||||
.map_err(|error| format!("清理 Agent DB 测试落盘后失败注入标记失败:{error}"))?;
|
||||
Err(format!(
|
||||
"测试注入 Agent DB 记录已落盘后返回失败:{}",
|
||||
expected_record_type.trim()
|
||||
))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("读取 Agent DB 测试落盘后失败注入标记失败:{error}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> {
|
||||
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record");
|
||||
@@ -1027,7 +1048,17 @@ fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Re
|
||||
verify_agent_db_storage_current(&storage)?;
|
||||
let line = serialize_agent_db_record(record)?;
|
||||
validate_agent_db_append_class_record_size(append_class, &line)?;
|
||||
append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)
|
||||
append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?;
|
||||
#[cfg(test)]
|
||||
take_agent_db_record_after_sync_failure_injection(
|
||||
root,
|
||||
serde_json::from_str::<serde_json::Value>(&line)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(|record| record.get("recordType"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -921,15 +921,38 @@ pub(crate) fn validate_manifest_required_visual_asset(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?;
|
||||
if canvas_project_id != art_spec_project_id {
|
||||
return Err(format!(
|
||||
"派生视觉资产与当前统一视觉规范图不属于同一画布项目:{expected_path}"
|
||||
));
|
||||
}
|
||||
if asset.source.reference_resource_ids.as_slice() != [art_spec_resource_id] {
|
||||
let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else {
|
||||
return Err(format!(
|
||||
"派生视觉资产未精确引用当前统一视觉规范图:{expected_path}"
|
||||
));
|
||||
};
|
||||
let original_provenance_matches =
|
||||
canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id;
|
||||
let rebound_local_source_matches = if original_provenance_matches {
|
||||
true
|
||||
} else {
|
||||
let art_spec_bytes = resolve_local_project_path(root, &art_spec.local_path)
|
||||
.ok()
|
||||
.and_then(|path| fs::read(path).ok())
|
||||
.ok_or_else(|| "读取统一视觉规范图失败".to_string())?;
|
||||
let source_identity = new_external_editor_source_identity(
|
||||
&art_spec.id,
|
||||
&format!("{:x}", Sha256::digest(&art_spec_bytes)),
|
||||
&art_spec.media_type,
|
||||
&art_spec.kind,
|
||||
)?;
|
||||
external_editor_remote_reference_matches_local_source_at(
|
||||
root,
|
||||
&manifest.project_id,
|
||||
canvas_project_id,
|
||||
reference_resource_id,
|
||||
&source_identity,
|
||||
)?
|
||||
};
|
||||
if !rebound_local_source_matches {
|
||||
return Err(format!(
|
||||
"派生视觉资产未绑定当前统一视觉规范图的本地内容身份:{expected_path}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,17 +46,22 @@ fn external_agent_runner_gui_owner_attachment_state(
|
||||
pub(super) fn register_external_agent_runner_gui_owner_attachment(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
config_dir: &Path,
|
||||
params: ExternalAgentRunnerRequestParams,
|
||||
) {
|
||||
mut params: ExternalAgentRunnerRequestParams,
|
||||
) -> Result<(), String> {
|
||||
let mut state = lock_unpoisoned(state);
|
||||
state.generation = state.generation.wrapping_add(1);
|
||||
let generation = state.generation;
|
||||
params.gui_owner_session_revision = Some(generation);
|
||||
if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() {
|
||||
write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?;
|
||||
}
|
||||
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
|
||||
generation,
|
||||
config_dir: config_dir.to_path_buf(),
|
||||
params,
|
||||
attached_boot_id: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F>(
|
||||
@@ -990,6 +995,7 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
|
||||
|
||||
pub(crate) fn attach_external_agent_runner_gui_owner(
|
||||
event_sink: &GameCreatorManifestInvalidationEventSink,
|
||||
gui_owner_epoch: &str,
|
||||
) -> Result<(), String> {
|
||||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
@@ -1003,6 +1009,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
|
||||
ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(event_sink.port),
|
||||
event_sink_token: Some(event_sink.token.clone()),
|
||||
gui_owner_epoch: Some(gui_owner_epoch.to_string()),
|
||||
platform_user_id: platform_session
|
||||
.as_ref()
|
||||
.map(|session| session.user_id.clone()),
|
||||
@@ -1015,7 +1022,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
|
||||
platform_auth_generation: platform_session.map(|session| session.generation),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
);
|
||||
)?;
|
||||
ensure_external_agent_runner(&config_dir).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -1025,67 +1032,130 @@ pub(crate) fn install_external_agent_runner_platform_session(
|
||||
api_base_url: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
remember_external_agent_runner_platform_session(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
Some((user_id, access_token, api_base_url)),
|
||||
generation,
|
||||
);
|
||||
let config_dir = external_agent_runner_config_dir()
|
||||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
|
||||
let endpoint = {
|
||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||
ensure_external_agent_runner(&config_dir)?
|
||||
};
|
||||
send_external_agent_runner_request(
|
||||
&endpoint,
|
||||
"platform.session.install",
|
||||
ExternalAgentRunnerRequestParams {
|
||||
platform_user_id: Some(user_id.to_string()),
|
||||
platform_access_token: Some(access_token.to_string()),
|
||||
platform_api_base_url: Some(api_base_url.to_string()),
|
||||
platform_auth_generation: Some(generation),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
synchronize_external_agent_runner_platform_session_with(
|
||||
|| {
|
||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||
remember_external_agent_runner_platform_session(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
Some((user_id, access_token, api_base_url)),
|
||||
generation,
|
||||
)
|
||||
.and_then(|_| ensure_external_agent_runner(&config_dir))
|
||||
.and_then(|endpoint| {
|
||||
validate_external_agent_runner_platform_session_attachment(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
Some((user_id, access_token, api_base_url)),
|
||||
generation,
|
||||
)
|
||||
})
|
||||
},
|
||||
|| shutdown_external_agent_runner_at(&config_dir),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> {
|
||||
remember_external_agent_runner_platform_session(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
None,
|
||||
generation,
|
||||
);
|
||||
let Some(config_dir) = external_agent_runner_config_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
|
||||
let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else {
|
||||
synchronize_external_agent_runner_platform_session_with(
|
||||
|| {
|
||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||
remember_external_agent_runner_platform_session(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
None,
|
||||
generation,
|
||||
)
|
||||
.and_then(|_| ensure_external_agent_runner(&config_dir))
|
||||
.and_then(|endpoint| {
|
||||
validate_external_agent_runner_platform_session_attachment(
|
||||
external_agent_runner_gui_owner_attachment_state(),
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
None,
|
||||
generation,
|
||||
)
|
||||
})
|
||||
},
|
||||
|| shutdown_external_agent_runner_at(&config_dir),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_external_agent_runner_platform_session_attachment(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
config_dir: &Path,
|
||||
endpoint: &ExternalAgentRunnerEndpoint,
|
||||
session: Option<(&str, &str, &str)>,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
let state = lock_unpoisoned(state);
|
||||
let registration = state.registration.as_ref().ok_or_else(|| {
|
||||
"Agent Runner 尚未建立带 owner epoch 的 GUI owner 登记,平台登录态拒绝下发".to_string()
|
||||
})?;
|
||||
let expected_user_id = session.map(|(user_id, _, _)| user_id);
|
||||
let expected_access_token = session.map(|(_, access_token, _)| access_token);
|
||||
let expected_api_base_url = session.map(|(_, _, api_base_url)| api_base_url);
|
||||
if registration.config_dir != config_dir
|
||||
|| registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())
|
||||
|| registration.params.gui_owner_epoch.is_none()
|
||||
|| registration.params.gui_owner_session_revision != Some(registration.generation)
|
||||
|| registration.params.platform_auth_generation != Some(generation)
|
||||
|| registration.params.platform_user_id.as_deref() != expected_user_id
|
||||
|| registration.params.platform_access_token.as_deref() != expected_access_token
|
||||
|| registration.params.platform_api_base_url.as_deref() != expected_api_base_url
|
||||
{
|
||||
return Err("Agent Runner 未确认当前 GUI owner epoch 的平台登录态".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn synchronize_external_agent_runner_platform_session_with(
|
||||
sync_attempt: impl FnOnce() -> Result<(), String>,
|
||||
fence_runner: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let sync_result = sync_attempt();
|
||||
let Err(sync_error) = sync_result else {
|
||||
return Ok(());
|
||||
};
|
||||
send_external_agent_runner_request(
|
||||
&endpoint,
|
||||
"platform.session.clear",
|
||||
ExternalAgentRunnerRequestParams {
|
||||
platform_auth_generation: Some(generation),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
match fence_runner() {
|
||||
Ok(()) => Err(format!(
|
||||
"{sync_error};为避免旧账号继续执行,Agent Runner 已停止,后续请求将按当前账号重建"
|
||||
)),
|
||||
Err(fence_error) => Err(format!(
|
||||
"{sync_error};阻断旧账号 Agent Runner 失败:{fence_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remember_external_agent_runner_platform_session(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
session: Option<(&str, &str, &str)>,
|
||||
generation: u64,
|
||||
) {
|
||||
) -> Result<(), String> {
|
||||
remember_external_agent_runner_platform_session_with(
|
||||
state,
|
||||
session,
|
||||
generation,
|
||||
write_external_agent_runner_gui_owner_claim_atomic,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn remember_external_agent_runner_platform_session_with(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
session: Option<(&str, &str, &str)>,
|
||||
generation: u64,
|
||||
write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let mut state = lock_unpoisoned(state);
|
||||
let Some(registration) = state.registration.as_mut() else {
|
||||
return;
|
||||
let Some(registration) = state.registration.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let current_generation = registration.params.platform_auth_generation.unwrap_or(0);
|
||||
if generation < current_generation {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
if generation == current_generation {
|
||||
match session {
|
||||
@@ -1096,23 +1166,43 @@ pub(super) fn remember_external_agent_runner_platform_session(
|
||||
&& registration.params.platform_api_base_url.as_deref()
|
||||
== Some(api_base_url) =>
|
||||
{
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
Some(_) => return,
|
||||
Some(_) => return Ok(()),
|
||||
None if registration.params.platform_user_id.is_none()
|
||||
&& registration.params.platform_access_token.is_none() =>
|
||||
{
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
state.generation = state.generation.wrapping_add(1);
|
||||
let registration_generation = state.generation;
|
||||
let claim = state.registration.as_ref().and_then(|registration| {
|
||||
registration
|
||||
.params
|
||||
.gui_owner_epoch
|
||||
.as_deref()
|
||||
.map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string()))
|
||||
});
|
||||
if let Some((config_dir, owner_epoch)) = claim {
|
||||
write_claim(&config_dir, &owner_epoch, registration_generation)?;
|
||||
}
|
||||
let registration = state
|
||||
.registration
|
||||
.as_mut()
|
||||
.expect("checked GUI owner registration must remain present while locked");
|
||||
registration.generation = registration_generation;
|
||||
registration.attached_boot_id = None;
|
||||
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
|
||||
registration.params.platform_access_token =
|
||||
session.map(|(_, access_token, _)| access_token.to_string());
|
||||
registration.params.platform_api_base_url =
|
||||
session.map(|(_, _, api_base_url)| api_base_url.to_string());
|
||||
registration.params.platform_auth_generation = Some(generation);
|
||||
registration.params.gui_owner_session_revision = Some(registration_generation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
|
||||
@@ -1405,6 +1495,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
|
||||
steer_id: steer_id.map(str::to_string),
|
||||
event_sink_port: None,
|
||||
event_sink_token: None,
|
||||
gui_owner_epoch: None,
|
||||
gui_owner_session_revision: None,
|
||||
platform_user_id: None,
|
||||
platform_access_token: None,
|
||||
platform_api_base_url: None,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::{endpoint::*, project_owner::*, protocol::*, state::*};
|
||||
use crate::configure_game_creator_manifest_invalidation_event_sink;
|
||||
use crate::{
|
||||
install_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
@@ -92,6 +95,134 @@ pub(super) fn external_agent_runner_request_agent(
|
||||
Ok(agent.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn apply_external_agent_runner_gui_owner_platform_session(
|
||||
state: &ExternalAgentRunnerServerState,
|
||||
params: &ExternalAgentRunnerRequestParams,
|
||||
) -> Result<(), String> {
|
||||
apply_external_agent_runner_gui_owner_attachment(state, params, None)
|
||||
}
|
||||
|
||||
fn apply_external_agent_runner_gui_owner_attachment(
|
||||
state: &ExternalAgentRunnerServerState,
|
||||
params: &ExternalAgentRunnerRequestParams,
|
||||
event_sink: Option<crate::GameCreatorManifestInvalidationEventSink>,
|
||||
) -> Result<(), String> {
|
||||
let requested_epoch = params
|
||||
.gui_owner_epoch
|
||||
.as_deref()
|
||||
.filter(|value| uuid::Uuid::parse_str(value).is_ok())
|
||||
.ok_or_else(|| "Agent Runner GUI owner 缺少有效 owner epoch".to_string())?;
|
||||
let requested_revision = params
|
||||
.gui_owner_session_revision
|
||||
.ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?;
|
||||
let config_dir = state
|
||||
.gui_owner_lock_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?;
|
||||
let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim);
|
||||
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?;
|
||||
if durable_claim.owner_epoch != requested_epoch
|
||||
|| durable_claim.session_revision != requested_revision
|
||||
{
|
||||
return Err("Agent Runner GUI owner claim 已过期".to_string());
|
||||
}
|
||||
let requested_claim = (requested_epoch.to_string(), requested_revision);
|
||||
let replace_claim = active_claim.as_ref() != Some(&requested_claim);
|
||||
let result = match (
|
||||
params.platform_user_id.as_deref(),
|
||||
params.platform_access_token.as_deref(),
|
||||
params.platform_api_base_url.as_deref(),
|
||||
params.platform_auth_generation,
|
||||
) {
|
||||
(Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => {
|
||||
if replace_claim {
|
||||
crate::replace_platform_session_for_gui_owner(
|
||||
user_id,
|
||||
access_token,
|
||||
api_base_url,
|
||||
generation,
|
||||
)
|
||||
} else {
|
||||
crate::install_platform_session_checked(
|
||||
user_id,
|
||||
access_token,
|
||||
api_base_url,
|
||||
generation,
|
||||
)
|
||||
}
|
||||
}
|
||||
(None, None, None, Some(generation)) => {
|
||||
if replace_claim {
|
||||
crate::clear_platform_session_for_gui_owner(generation);
|
||||
Ok(())
|
||||
} else {
|
||||
crate::clear_platform_session_checked(generation)
|
||||
}
|
||||
}
|
||||
(None, None, None, None) if replace_claim => {
|
||||
crate::clear_platform_session_for_gui_owner(0);
|
||||
Ok(())
|
||||
}
|
||||
(None, None, None, None) => Ok(()),
|
||||
_ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()),
|
||||
};
|
||||
result?;
|
||||
let committed_claim = match read_external_agent_runner_gui_owner_claim(config_dir) {
|
||||
Ok(claim) => claim,
|
||||
Err(error) => {
|
||||
*active_claim = None;
|
||||
crate::clear_platform_session_for_gui_owner(0);
|
||||
return Err(format!(
|
||||
"Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if committed_claim.owner_epoch != requested_epoch
|
||||
|| committed_claim.session_revision != requested_revision
|
||||
{
|
||||
*active_claim = None;
|
||||
crate::clear_platform_session_for_gui_owner(0);
|
||||
return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string());
|
||||
}
|
||||
if let Some(event_sink) = event_sink {
|
||||
install_game_creator_manifest_invalidation_event_sink(event_sink);
|
||||
}
|
||||
*active_claim = Some(requested_claim);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_external_agent_runner_gui_owner_claim_current(
|
||||
state: &ExternalAgentRunnerServerState,
|
||||
) -> Result<(), String> {
|
||||
let config_dir = state
|
||||
.gui_owner_lock_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?;
|
||||
let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim);
|
||||
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir);
|
||||
let matches = durable_claim.as_ref().is_ok_and(|claim| {
|
||||
active_claim.as_ref() == Some(&(claim.owner_epoch.clone(), claim.session_revision))
|
||||
});
|
||||
if matches {
|
||||
return Ok(());
|
||||
}
|
||||
*active_claim = None;
|
||||
crate::clear_platform_session_for_gui_owner(0);
|
||||
match durable_claim {
|
||||
Ok(_) => Err(
|
||||
"authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离"
|
||||
.to_string(),
|
||||
),
|
||||
Err(error) => Err(format!(
|
||||
"authentication-required: Agent Runner GUI owner claim 无法核验,平台登录态已隔离:{error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool {
|
||||
method == "mcp.status" || method.starts_with("runtime.")
|
||||
}
|
||||
|
||||
pub(super) fn external_agent_runner_request_session_id(
|
||||
request: &ExternalAgentRunnerRequest,
|
||||
) -> Result<Option<String>, String> {
|
||||
@@ -406,7 +537,8 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||||
claim_owner: impl Fn(&Path) -> Result<ExternalAgentRunnerProjectExecutionOwnerClaim, String>,
|
||||
) -> ExternalAgentRunnerResponse {
|
||||
let fingerprint = external_agent_runner_request_fingerprint(request);
|
||||
{
|
||||
let use_request_cache = request.method != "runner.attach_gui_owner";
|
||||
if use_request_cache {
|
||||
let cache = lock_unpoisoned(&state.write_request_cache);
|
||||
if let Some(cached) = cache.find(&request.request_id) {
|
||||
if cached.fingerprint == fingerprint {
|
||||
@@ -466,15 +598,17 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||||
};
|
||||
|
||||
let mut cache = lock_unpoisoned(&state.write_request_cache);
|
||||
if let Some(cached) = cache.find(&request.request_id) {
|
||||
if cached.fingerprint == fingerprint {
|
||||
return cached.response.clone();
|
||||
if use_request_cache {
|
||||
if let Some(cached) = cache.find(&request.request_id) {
|
||||
if cached.fingerprint == fingerprint {
|
||||
return cached.response.clone();
|
||||
}
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"request-id-conflict",
|
||||
"同一 requestId 不能用于不同请求",
|
||||
);
|
||||
}
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"request-id-conflict",
|
||||
"同一 requestId 不能用于不同请求",
|
||||
);
|
||||
}
|
||||
|
||||
let response = match request.method.as_str() {
|
||||
@@ -644,38 +778,28 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||||
"Agent Runner GUI owner 缺少 manifest 事件接收端".to_string()
|
||||
})
|
||||
.and_then(|(port, token)| {
|
||||
configure_game_creator_manifest_invalidation_event_sink(port, token)
|
||||
validate_game_creator_manifest_invalidation_event_sink(port, token)
|
||||
});
|
||||
if let Err(error) = event_sink {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"event-sink-invalid",
|
||||
redact_runner_secret(&error, &token),
|
||||
);
|
||||
}
|
||||
if let (
|
||||
Some(user_id),
|
||||
Some(access_token),
|
||||
Some(api_base_url),
|
||||
Some(generation),
|
||||
) = (
|
||||
request.params.platform_user_id.as_deref(),
|
||||
request.params.platform_access_token.as_deref(),
|
||||
request.params.platform_api_base_url.as_deref(),
|
||||
request.params.platform_auth_generation,
|
||||
) {
|
||||
if let Err(error) = crate::install_platform_session(
|
||||
user_id,
|
||||
access_token,
|
||||
api_base_url,
|
||||
generation,
|
||||
) {
|
||||
let event_sink = match event_sink {
|
||||
Ok(event_sink) => event_sink,
|
||||
Err(error) => {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
error,
|
||||
"event-sink-invalid",
|
||||
redact_runner_secret(&error, &token),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(error) = apply_external_agent_runner_gui_owner_attachment(
|
||||
state,
|
||||
&request.params,
|
||||
Some(event_sink),
|
||||
) {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
error,
|
||||
);
|
||||
}
|
||||
state.gui_owner_attached.store(true, Ordering::Release);
|
||||
ExternalAgentRunnerResponse::success(
|
||||
@@ -695,40 +819,12 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||||
),
|
||||
}
|
||||
}
|
||||
"platform.session.install" => {
|
||||
let result = request
|
||||
.params
|
||||
.platform_user_id
|
||||
.as_deref()
|
||||
.zip(request.params.platform_access_token.as_deref())
|
||||
.zip(request.params.platform_api_base_url.as_deref())
|
||||
.zip(request.params.platform_auth_generation)
|
||||
.ok_or_else(|| "平台登录态同步参数不完整".to_string())
|
||||
.and_then(|(((user_id, access_token), api_base_url), generation)| {
|
||||
crate::install_platform_session(user_id, access_token, api_base_url, generation)
|
||||
});
|
||||
match result {
|
||||
Ok(()) => ExternalAgentRunnerResponse::success(
|
||||
&request.request_id,
|
||||
json!({ "installed": true }),
|
||||
),
|
||||
Err(error) => ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
error,
|
||||
),
|
||||
}
|
||||
}
|
||||
"platform.session.clear" => {
|
||||
let Some(generation) = request.params.platform_auth_generation else {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-invalid",
|
||||
"平台登录态清除缺少 authGeneration",
|
||||
);
|
||||
};
|
||||
crate::clear_platform_session(generation);
|
||||
ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true }))
|
||||
"platform.session.install" | "platform.session.clear" => {
|
||||
ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-epoch-required",
|
||||
"平台登录态只能通过当前 GUI owner epoch 的 runner.attach_gui_owner 同步",
|
||||
)
|
||||
}
|
||||
"runner.shutdown" | "shutdown" => {
|
||||
let provider_requests_interrupted =
|
||||
@@ -862,12 +958,14 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||||
"Agent Runner 不支持该方法",
|
||||
),
|
||||
};
|
||||
cache_external_agent_runner_response_if_cacheable(
|
||||
&mut cache,
|
||||
&request.request_id,
|
||||
&fingerprint,
|
||||
&response,
|
||||
);
|
||||
if use_request_cache {
|
||||
cache_external_agent_runner_response_if_cacheable(
|
||||
&mut cache,
|
||||
&request.request_id,
|
||||
&fingerprint,
|
||||
&response,
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -904,6 +1002,17 @@ pub(super) fn handle_external_agent_runner_request(
|
||||
"Agent Runner method 无效",
|
||||
);
|
||||
}
|
||||
if state.gui_owner_attached.load(Ordering::Acquire)
|
||||
&& external_agent_runner_method_requires_current_gui_owner_claim(&request.method)
|
||||
{
|
||||
if let Err(error) = validate_external_agent_runner_gui_owner_claim_current(state) {
|
||||
return ExternalAgentRunnerResponse::failure(
|
||||
&request.request_id,
|
||||
"platform-session-claim-stale",
|
||||
redact_runner_secret(&error, &expected_token),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match request.method.as_str() {
|
||||
"runner.ping" => ExternalAgentRunnerResponse::success(
|
||||
|
||||
@@ -309,6 +309,38 @@ pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> Pa
|
||||
config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)
|
||||
}
|
||||
|
||||
pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf {
|
||||
config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ExternalAgentRunnerGuiOwnerClaim {
|
||||
pub(super) owner_epoch: String,
|
||||
pub(super) session_revision: u64,
|
||||
}
|
||||
|
||||
pub(super) fn read_external_agent_runner_gui_owner_claim(
|
||||
config_dir: &Path,
|
||||
) -> Result<ExternalAgentRunnerGuiOwnerClaim, String> {
|
||||
let path = external_agent_runner_gui_owner_claim_path(config_dir);
|
||||
let file = open_external_agent_runner_endpoint_file(&path)?;
|
||||
validate_external_agent_runner_endpoint_metadata(&file, &path)?;
|
||||
let mut content = Vec::new();
|
||||
file.take(EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES + 1)
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|error| format!("读取 Agent Runner GUI owner claim 失败:{error}"))?;
|
||||
if content.len() as u64 > EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES {
|
||||
return Err("Agent Runner GUI owner claim 超过大小上限".to_string());
|
||||
}
|
||||
let claim = serde_json::from_slice::<ExternalAgentRunnerGuiOwnerClaim>(&content)
|
||||
.map_err(|_| "解析 Agent Runner GUI owner claim 失败".to_string())?;
|
||||
if uuid::Uuid::parse_str(&claim.owner_epoch).is_err() {
|
||||
return Err("Agent Runner GUI owner claim 缺少有效 ownerEpoch".to_string());
|
||||
}
|
||||
Ok(claim)
|
||||
}
|
||||
|
||||
pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result<bool, String> {
|
||||
match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? {
|
||||
Some(lock) => {
|
||||
@@ -905,10 +937,13 @@ pub(crate) fn acquire_external_agent_runner_gui_owner_lock(
|
||||
else {
|
||||
return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string());
|
||||
};
|
||||
let owner_epoch = uuid::Uuid::new_v4().to_string();
|
||||
let acquired_at = unix_millis();
|
||||
let diagnostic = serde_json::to_vec(&json!({
|
||||
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
"pid": std::process::id(),
|
||||
"acquiredAt": unix_millis(),
|
||||
"ownerEpoch": owner_epoch,
|
||||
"acquiredAt": acquired_at,
|
||||
}))
|
||||
.map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?;
|
||||
file.set_len(0)
|
||||
@@ -921,5 +956,68 @@ pub(crate) fn acquire_external_agent_runner_gui_owner_lock(
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(ExternalAgentRunnerGuiOwnerLock { _file: file })
|
||||
write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?;
|
||||
Ok(ExternalAgentRunnerGuiOwnerLock {
|
||||
_file: file,
|
||||
owner_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn write_external_agent_runner_gui_owner_claim_atomic(
|
||||
config_dir: &Path,
|
||||
owner_epoch: &str,
|
||||
session_revision: u64,
|
||||
) -> Result<(), String> {
|
||||
let path = external_agent_runner_gui_owner_claim_path(config_dir);
|
||||
let content = serde_json::to_vec(&json!({
|
||||
"schemaVersion": "agent-runner-gui-owner-claim.v1",
|
||||
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||
"pid": std::process::id(),
|
||||
"ownerEpoch": owner_epoch,
|
||||
"sessionRevision": session_revision,
|
||||
"updatedAt": unix_millis(),
|
||||
}))
|
||||
.map_err(|error| format!("生成 Agent Runner GUI owner claim 失败:{error}"))?;
|
||||
if content.len() as u64 > EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES {
|
||||
return Err("Agent Runner GUI owner claim 超过大小上限".to_string());
|
||||
}
|
||||
let mut temporary = None;
|
||||
for sequence in 0..16_u32 {
|
||||
let candidate = config_dir.join(format!(
|
||||
".{EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME}.{}.{}.tmp",
|
||||
std::process::id(),
|
||||
sequence
|
||||
));
|
||||
match private_create_new_file(&candidate) {
|
||||
Ok(file) => {
|
||||
temporary = Some((candidate, file));
|
||||
break;
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"创建 Agent Runner GUI owner claim 临时文件失败:{error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (temporary_path, mut temporary_file) = temporary
|
||||
.ok_or_else(|| "创建 Agent Runner GUI owner claim 临时文件重试耗尽".to_string())?;
|
||||
let write_result = temporary_file
|
||||
.write_all(&content)
|
||||
.and_then(|_| temporary_file.sync_all());
|
||||
drop(temporary_file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&temporary_path);
|
||||
return Err(format!("写入 Agent Runner GUI owner claim 失败:{error}"));
|
||||
}
|
||||
if let Err(error) = replace_file_atomically(&temporary_path, &path) {
|
||||
let _ = fs::remove_file(&temporary_path);
|
||||
return Err(format!("提交 Agent Runner GUI owner claim 失败:{error}"));
|
||||
}
|
||||
let persisted = read_external_agent_runner_gui_owner_claim(config_dir)?;
|
||||
if persisted.owner_epoch != owner_epoch || persisted.session_revision != session_revision {
|
||||
return Err("Agent Runner GUI owner claim 写入后回读不一致".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6;
|
||||
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7;
|
||||
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str =
|
||||
"agent-runner.gui-owner.lock";
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str =
|
||||
"agent-runner.gui-owner.claim.json";
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock";
|
||||
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_FILE_NAME: &str =
|
||||
"execution-owner.json";
|
||||
@@ -269,6 +271,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) event_sink_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) gui_owner_epoch: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) gui_owner_session_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) platform_user_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) platform_access_token: Option<String>,
|
||||
|
||||
@@ -152,18 +152,27 @@ pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Resu
|
||||
}
|
||||
}
|
||||
|
||||
fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) -> bool {
|
||||
if !state.gui_owner_attached.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
|
||||
Ok(true) => {
|
||||
let _ = validate_external_agent_runner_gui_owner_claim_current(state);
|
||||
false
|
||||
}
|
||||
Ok(false) | Err(_) => {
|
||||
request_external_agent_runner_forced_shutdown(state);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost(
|
||||
state: &ExternalAgentRunnerServerState,
|
||||
) -> Result<bool, String> {
|
||||
if !state.gui_owner_attached.load(Ordering::Acquire) {
|
||||
return Ok(false);
|
||||
}
|
||||
if external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path)? {
|
||||
return Ok(false);
|
||||
}
|
||||
request_external_agent_runner_forced_shutdown(state);
|
||||
Ok(true)
|
||||
Ok(external_agent_runner_watchdog_tick(state))
|
||||
}
|
||||
|
||||
pub(super) fn spawn_external_agent_runner_gui_owner_watchdog(
|
||||
@@ -174,25 +183,10 @@ pub(super) fn spawn_external_agent_runner_gui_owner_watchdog(
|
||||
thread::Builder::new()
|
||||
.name("agent-runner-gui-owner-watchdog".to_string())
|
||||
.spawn(move || loop {
|
||||
if !state.gui_owner_attached.load(Ordering::Acquire) {
|
||||
if !external_agent_runner_watchdog_tick(&state) {
|
||||
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL);
|
||||
continue;
|
||||
}
|
||||
let owner_lost =
|
||||
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
|
||||
Ok(locked) => !locked,
|
||||
Err(_) => true,
|
||||
};
|
||||
if !owner_lost {
|
||||
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.draining.store(true, Ordering::Release);
|
||||
state
|
||||
.force_shutdown_requested
|
||||
.store(true, Ordering::Release);
|
||||
state.shutdown_requested.store(true, Ordering::Release);
|
||||
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT);
|
||||
let _ = crate::agent::shutdown_game_creator_codex_app_servers();
|
||||
remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id);
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) struct ExternalAgentRunnerServerState {
|
||||
pub(super) shutdown_requested: AtomicBool,
|
||||
pub(super) force_shutdown_requested: AtomicBool,
|
||||
pub(super) gui_owner_attached: AtomicBool,
|
||||
pub(super) gui_owner_platform_session_claim: Mutex<Option<(String, u64)>>,
|
||||
pub(super) draining: AtomicBool,
|
||||
pub(super) active_connections: AtomicUsize,
|
||||
pub(super) known_roots: Mutex<BTreeSet<PathBuf>>,
|
||||
@@ -89,6 +90,7 @@ impl ExternalAgentRunnerServerState {
|
||||
shutdown_requested: AtomicBool::new(false),
|
||||
force_shutdown_requested: AtomicBool::new(false),
|
||||
gui_owner_attached: AtomicBool::new(false),
|
||||
gui_owner_platform_session_claim: Mutex::new(None),
|
||||
draining: AtomicBool::new(false),
|
||||
active_connections: AtomicUsize::new(0),
|
||||
known_roots: Mutex::new(BTreeSet::new()),
|
||||
@@ -232,6 +234,13 @@ pub(super) struct ExternalAgentRunnerInstanceLock {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExternalAgentRunnerGuiOwnerLock {
|
||||
pub(super) _file: File,
|
||||
pub(super) owner_epoch: String,
|
||||
}
|
||||
|
||||
impl ExternalAgentRunnerGuiOwnerLock {
|
||||
pub(crate) fn owner_epoch(&self) -> &str {
|
||||
&self.owner_epoch
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ExternalAgentRunnerProjectOwnerStorage {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user