diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 5fd595ec1..80216aa36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,5 +1,169 @@ use super::*; +const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; +const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; + +static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct ManifestWriteLock { + _file: File, +} + +fn manifest_lock_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.lock", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json") + )) +} + +fn acquire_manifest_write_lock(path: &Path) -> Result { + for attempt in 0..MANIFEST_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_manifest_write_lock_file(path)? { + return Ok(ManifestWriteLock { _file: file }); + } + if attempt + 1 < MANIFEST_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(MANIFEST_LOCK_WAIT_MILLIS)); + } + } + Err("manifest 正在被其他进程写入,请稍后重试".to_string()) +} + +#[cfg(unix)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + let mut options = fs::OpenOptions::new(); + options + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + let file = options + .open(&lock_path) + .map_err(|error| format!("安全打开 manifest 锁失败:{}: {error}", lock_path.display()))?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取 manifest 锁句柄元数据失败:{}: {error}", + lock_path.display() + ) + })?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "manifest 锁必须是当前用户持有的无硬链接普通文件:{}", + lock_path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?; + let path_metadata = fs::symlink_metadata(&lock_path) + .map_err(|error| format!("复核 manifest 锁路径失败:{}: {error}", lock_path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "manifest 锁路径在安全打开期间发生替换:{}", + lock_path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核 manifest 锁句柄失败:{}: {error}", lock_path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "manifest 锁必须由当前用户持有且权限为 0600:{}", + lock_path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + if let Ok(metadata) = fs::symlink_metadata(&lock_path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "manifest 锁必须是普通文件且不能是 reparse point:{}", + lock_path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&lock_path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + Err(format!( + "当前平台不支持 manifest 系统文件锁:{}", + manifest_lock_path(path).display() + )) +} + pub(crate) fn init_local_game_project_at( root: &Path, project_id: &str, @@ -712,8 +876,27 @@ pub(crate) fn write_manifest( path: &Path, manifest: &GameCreationAppManifest, ) -> Result<(), String> { + write_manifest_with_lock_hook(path, manifest, || {}) +} + +fn write_manifest_with_lock_hook( + path: &Path, + manifest: &GameCreationAppManifest, + after_lock: F, +) -> Result<(), String> +where + F: FnOnce(), +{ validate_game_iteration_versions(&manifest.versions) .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; + let payload = serde_json::to_string_pretty(manifest) + .map_err(|error| format!("序列化 manifest 失败:{error}"))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; + } + let _write_lock = acquire_manifest_write_lock(path)?; + after_lock(); if manifest_storage_exists(path)? { let existing = read_manifest(path)?; if existing.versions.len() > manifest.versions.len() @@ -726,12 +909,6 @@ pub(crate) fn write_manifest( return Err("项目版本记录写入后不可修改、删除或重排".to_string()); } } - let payload = serde_json::to_string_pretty(manifest) - .map_err(|error| format!("序列化 manifest 失败:{error}"))?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; - } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("manifest 必须是普通文件".to_string()); @@ -762,7 +939,12 @@ pub(crate) fn write_manifest( temp_path.display() ) })?; - install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to)) + install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + let installed = read_manifest(path)?; + if installed != *manifest { + return Err("manifest 安装后回读与待写入内容不一致".to_string()); + } + Ok(()) } pub(crate) fn sanitize_file_name(file_name: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 1388bfe92..cf4c331fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -96,6 +96,73 @@ fn manifest_versions_are_append_only_at_the_storage_boundary() { fs::remove_dir_all(root).ok(); } +#[test] +fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_snapshot() { + let root = unique_manifest_test_root("versions-concurrent-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut stale_manifest = new_game_creation_app_manifest("project-versioned", "并发版本项目"); + stale_manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &stale_manifest).expect("write initial version"); + + let mut newer_manifest = stale_manifest.clone(); + newer_manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + let (newer_locked_tx, newer_locked_rx) = mpsc::channel(); + let (release_newer_tx, release_newer_rx) = mpsc::channel(); + let newer_path = manifest_path.clone(); + let newer_writer = std::thread::spawn(move || { + write_manifest_with_lock_hook(&newer_path, &newer_manifest, || { + newer_locked_tx + .send(()) + .expect("signal newer lock acquired"); + release_newer_rx.recv().expect("release newer writer"); + }) + }); + newer_locked_rx + .recv_timeout(Duration::from_secs(2)) + .expect("newer writer acquires manifest lock"); + + let (stale_started_tx, stale_started_rx) = mpsc::channel(); + let stale_path = manifest_path.clone(); + let stale_writer = std::thread::spawn(move || { + stale_started_tx + .send(()) + .expect("signal stale writer started"); + write_manifest(&stale_path, &stale_manifest) + }); + stale_started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("stale writer starts while newer writer holds lock"); + release_newer_tx.send(()).expect("release newer writer"); + + newer_writer + .join() + .expect("join newer writer") + .expect("install newer manifest"); + let stale_error = stale_writer + .join() + .expect("join stale writer") + .expect_err("reject stale manifest after newer version is installed"); + assert!( + stale_error.contains("不可修改、删除或重排"), + "{stale_error}" + ); + let installed = read_manifest(&manifest_path).expect("read final manifest"); + assert_eq!(installed.versions.len(), 2); + assert_eq!(installed.versions[1].version_id, "version-child"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_install_uses_previous_when_direct_replace_fails() { let root = unique_manifest_test_root("replace-fallback"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 917071ea2..5a4a36fb1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -327,7 +327,11 @@ pub(crate) fn build_project_resource_graph( .iter() .map(|asset| (asset.id.clone(), asset)) .collect::>(); - let audit_producer_by_asset_id = audit_asset_producers(agent_db_records, &task_ids); + let audit_producer_by_asset_id = if producer_mapping_truncated { + BTreeMap::new() + } else { + audit_asset_producers(agent_db_records, &task_ids) + }; let mut resource_ids_by_manifest_asset = BTreeMap::>::new(); for resource in resource_by_id.values() { @@ -775,6 +779,60 @@ mod tests { ); } + #[test] + fn graph_fails_closed_for_audit_producers_when_agent_db_tail_is_truncated() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ], + true, + ); + + assert!(graph.producer_mapping_truncated); + assert!(graph.producer_assignments.is_empty()); + assert!(graph.task_flows.is_empty()); + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.reference_edges[0].source_resource_id, "asset:spec"); + assert_eq!(graph.reference_edges[0].target_resource_id, "asset:ui"); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + #[test] fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() { let manifest = manifest( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7d2652110..f4f42d8f7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -565,6 +565,10 @@ type AppProps = { supervisorChatOnly?: boolean; gameChatOnly?: boolean; initialSupervisorMessage?: string; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], @@ -579,6 +583,7 @@ export function App({ supervisorChatOnly = false, gameChatOnly = false, initialSupervisorMessage = '', + onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, @@ -10434,6 +10439,18 @@ export function App({ const professionalResultCandidateKey = professionalResultCandidates .map((candidate) => `${candidate.agentId}:${candidate.runtimeUpdatedAt}`) .join('|'); + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) { + return; + } + onManifestChange(nextProjectPath, manifest); + }, [ + localProject?.projectPath, + manifest, + onManifestChange, + projectSupervisorOnly, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e9a84d703..7cd31249c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useCallback, useState } from 'react'; import { launcherNotifications } from '../../app/constants'; import { closeDialogOnEscape } from '../../app/dialogs'; @@ -45,6 +45,7 @@ export function WorkspaceLauncherShell({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, @@ -55,6 +56,24 @@ export function WorkspaceLauncherShell({ createHomeDraft, openProject, } = homeProject; + const syncActiveProjectManifest = useCallback( + ( + sourceProjectPath: string, + manifest: NonNullable['manifest'], + ) => { + setCurrentProjectContext((current) => { + if ( + !current || + current.projectPath !== sourceProjectPath || + current.manifest === manifest + ) { + return current; + } + return { ...current, manifest }; + }); + }, + [setCurrentProjectContext], + ); function showLauncherNotice(title: string) { setLauncherNotice({ @@ -163,6 +182,7 @@ export function WorkspaceLauncherShell({ initialProjectPath={currentProjectContext.projectPath} initialProjectManifest={currentProjectContext.manifest} projectSupervisorOnly + onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 624d2bec5..c615e387d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -34,6 +34,10 @@ export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; projectSupervisorOnly?: boolean; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 9d15570cb..055d266f0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -430,6 +430,7 @@ export function useHomeProjectCreation({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d80e496b0..85ab3edff 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3899,6 +3899,18 @@ iframe.preview-frame { pointer-events: none; } +.game-resource-dependency-description { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + .game-resource-dependency-edge, .game-resource-dependency-edge path { fill: none; @@ -3908,7 +3920,7 @@ iframe.preview-frame { } .game-resource-dependency-edge--reference { - stroke: #f28a52; + stroke: #c45f20; stroke-width: 2.4px; opacity: 1; } @@ -3934,7 +3946,7 @@ iframe.preview-frame { } .game-resource-dependency-marker--reference path { - fill: #f28a52; + fill: #c45f20; stroke-linejoin: round; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 11b813a51..8e186143d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -22,6 +22,7 @@ import { type ReactNode, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -440,8 +441,10 @@ export default function ProjectDevelopmentView({ const [mediaDuration, setMediaDuration] = useState(null); const resourceCanvasRef = useRef(null); const resourceFocusRef = useRef(null); + const resourceFocusTriggerIdRef = useRef(null); const resourceListScrollRef = useRef({ left: 0, top: 0 }); const restoreResourceListScrollRef = useRef(false); + const dependencyDescriptionId = useId(); const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); @@ -650,6 +653,56 @@ export default function ProjectDevelopmentView({ ), [visibleResources], ); + const dependencyRelationshipDescriptions = useMemo(() => { + if (sortMode !== 'dependency') { + return []; + } + const labelByResourceId = new Map( + resources.map((resource) => [resource.id, resource.label]), + ); + const categoryByResourceId = new Map( + resources.map((resource) => [resource.id, resource.category]), + ); + const descriptions = new Set( + resourceGraph.referenceEdges.flatMap((edge) => + visibleResourceIds.has(edge.sourceResourceId) && + visibleResourceIds.has(edge.targetResourceId) + ? [ + `${labelByResourceId.get(edge.targetResourceId) ?? edge.targetResourceId} 引用 ${ + labelByResourceId.get(edge.sourceResourceId) ?? + edge.sourceResourceId + }${edge.cyclic ? ',检测到依赖环' : ''}`, + ] + : [], + ), + ); + for (const flow of resourceGraph.taskFlows) { + for (const category of categoryOrder) { + const sourceLabels = flow.sourceResourceIds + .filter( + (resourceId) => + visibleResourceIds.has(resourceId) && + categoryByResourceId.get(resourceId) === category, + ) + .map((resourceId) => labelByResourceId.get(resourceId) ?? resourceId); + const targetLabels = flow.targetResourceIds + .filter( + (resourceId) => + visibleResourceIds.has(resourceId) && + categoryByResourceId.get(resourceId) === category, + ) + .map((resourceId) => labelByResourceId.get(resourceId) ?? resourceId); + if (sourceLabels.length > 0 && targetLabels.length > 0) { + descriptions.add( + `任务 ${flow.sourceTaskId} 的资源 ${sourceLabels.join('、')} 流向任务 ${ + flow.targetTaskId + } 的资源 ${targetLabels.join('、')}${flow.cyclic ? ',检测到依赖环' : ''}`, + ); + } + } + } + return Array.from(descriptions); + }, [resourceGraph, resources, sortMode, visibleResourceIds]); const resourcePositionsByCategory = useMemo( () => new Map( @@ -934,8 +987,16 @@ export default function ProjectDevelopmentView({ if (canvas) { canvas.scrollLeft = resourceListScrollRef.current.left; canvas.scrollTop = resourceListScrollRef.current.top; - restoreResourceListScrollRef.current = false; + const triggerResourceId = resourceFocusTriggerIdRef.current; + if (triggerResourceId) { + Array.from( + canvas.querySelectorAll('[data-resource-id]'), + ) + .find((card) => card.dataset.resourceId === triggerResourceId) + ?.focus({ preventScroll: true }); + } } + restoreResourceListScrollRef.current = false; }, [focusedResource]); const handleResourceSelect = useCallback((resourceId: string) => { @@ -946,6 +1007,7 @@ export default function ProjectDevelopmentView({ top: canvas.scrollTop, }; } + resourceFocusTriggerIdRef.current = resourceId; setSelectedResourceId(resourceId); setFocusedResourceId(resourceId); }, []); @@ -1323,8 +1385,28 @@ export default function ProjectDevelopmentView({ aria-label={ sortMode === 'dependency' ? '资源依赖视图' : '资源类型视图' } + aria-describedby={ + sortMode === 'dependency' && + dependencyRelationshipDescriptions.length > 0 + ? dependencyDescriptionId + : undefined + } aria-busy={resourceLayoutSaving} > + {sortMode === 'dependency' && + dependencyRelationshipDescriptions.length > 0 ? ( +
+

当前资源关系:

+
    + {dependencyRelationshipDescriptions.map((description) => ( +
  • {description}
  • + ))} +
+
+ ) : null}
{sortMode === 'dependency' ? ( = new Map< string, ProjectResourceTaskFlow >(); -const emptyReferenceEdgeMap: ReadonlyMap< - string, - ProjectResourceReferenceEdge -> = new Map(); +const emptyReferenceEdgeMap: ReadonlyMap = + new Map(); export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors = { @@ -126,6 +124,7 @@ function uniqueSorted(values: Iterable) { export function normalizeProjectResourceGraph( readModel: ProjectResourceGraphReadModel, ): ProjectResourceGraph { + const producerMappingTruncated = Boolean(readModel.producerMappingTruncated); const resourceIds = new Set(uniqueSorted(readModel.resourceIds)); const referenceEdges = readModel.referenceEdges .filter( @@ -136,7 +135,7 @@ export function normalizeProjectResourceGraph( ) .sort((left, right) => left.id.localeCompare(right.id)); const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id)); - const taskFlows = readModel.taskFlows + const taskFlows = (producerMappingTruncated ? [] : readModel.taskFlows) .flatMap((flow) => { if (flow.kind !== 'task-flow') { return []; @@ -174,9 +173,7 @@ export function normalizeProjectResourceGraph( ), ), referenceEdgeIds: new Set( - index.referenceEdgeIds.filter((edgeId) => - referenceEdgeIds.has(edgeId), - ), + index.referenceEdgeIds.filter((edgeId) => referenceEdgeIds.has(edgeId)), ), taskFlowIds: new Set( index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)), @@ -185,13 +182,17 @@ export function normalizeProjectResourceGraph( } const producerTaskIdByResourceId = new Map(); const dependencyDepthByResourceId = new Map(); - for (const assignment of readModel.producerAssignments) { + for (const assignment of producerMappingTruncated + ? [] + : readModel.producerAssignments) { if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) { continue; } producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId); } - for (const depth of readModel.dependencyDepths) { + for (const depth of producerMappingTruncated + ? [] + : readModel.dependencyDepths) { if ( resourceIds.has(depth.resourceId) && Number.isSafeInteger(depth.dependencyDepth) && @@ -224,8 +225,10 @@ export function normalizeProjectResourceGraph( resourceIds.has(resourceId), ), ), - cyclicTaskIds: new Set(readModel.cyclicTaskIds), - producerMappingTruncated: Boolean(readModel.producerMappingTruncated), + cyclicTaskIds: new Set( + producerMappingTruncated ? [] : readModel.cyclicTaskIds, + ), + producerMappingTruncated, }; } @@ -240,12 +243,8 @@ export function projectResourceGraphNeighbors( if (!index) { return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS; } - const upstreamResourceIds = new Set( - index.upstreamReferenceResourceIds, - ); - const downstreamResourceIds = new Set( - index.downstreamReferenceResourceIds, - ); + const upstreamResourceIds = new Set(index.upstreamReferenceResourceIds); + const downstreamResourceIds = new Set(index.downstreamReferenceResourceIds); const connectedEdgeIds = new Set(index.referenceEdgeIds); for (const flowId of index.taskFlowIds) { const flow = graph.taskFlowById.get(flowId); diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 0f4e20e51..8a0edb6ce 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -484,17 +484,32 @@ describe('ResourceDependencyOverlay', () => { } }); - it('uses a bright persistent orange without selection-dependent edge styles', () => { + it('uses a persistent orange with at least 3:1 canvas contrast', () => { const styles = readFileSync( resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); expect(styles).toMatch( - /\.game-resource-dependency-edge--reference\s*\{[^}]*stroke:\s*#f28a52[^}]*opacity:\s*1/s, + /\.game-resource-dependency-edge--reference\s*\{[^}]*stroke:\s*#c45f20[^}]*opacity:\s*1/s, ); expect(styles).toMatch( - /\.game-resource-dependency-marker--reference path\s*\{[^}]*fill:\s*#f28a52/s, + /\.game-resource-dependency-marker--reference path\s*\{[^}]*fill:\s*#c45f20/s, ); + const luminance = (hex: string) => { + const channels = hex + .match(/[a-f\d]{2}/giu)! + .map((value) => Number.parseInt(value, 16) / 255) + .map((value) => + value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, + ); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }; + const lineLuminance = luminance('c45f20'); + const canvasLuminance = luminance('fffdfa'); + const contrast = + (Math.max(lineLuminance, canvasLuminance) + 0.05) / + (Math.min(lineLuminance, canvasLuminance) + 0.05); + expect(contrast).toBeGreaterThanOrEqual(3); expect(styles).not.toMatch( /\.game-resource-dependency-edge\.is-(?:highlighted|dimmed)/, ); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 96bee78ed..55307ddb4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -7,18 +7,171 @@ import { fireEvent, it, nativeClipboardMock, + React, + render, renderAppAt, renderLauncherAgentChatAt, renderLauncherAt, renderLauncherProjectsAt, screen, selectDeveloperAgentChatMode, + testAuthUser, vi, waitFor, within, } from './harness'; +import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; +import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; export function registerClientHomeTests() { + it('projects Supervisor manifest updates into the open workbench without reopening the project', async () => { + const projectPath = '/tmp/live-manifest-workbench'; + const initialManifest = createGameCreationAppManifest( + 'live-manifest-project', + '实时清单项目', + ); + const updatedManifest = { + ...initialManifest, + tasks: initialManifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ), + assets: [ + { + id: 'live-hero', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/live-hero.png', + source: { kind: 'generated' as const, taskId: 'art-asset-plan' }, + }, + ], + versions: [ + { + versionId: 'version-live-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [{ slotId: 'hero', resourceId: 'live-hero' }], + createdReason: 'initial' as const, + createdAt: 1, + }, + ], + }; + function ManifestPushingSupervisor({ + initialProjectPath, + onManifestChange, + }: ProjectSupervisorComponentProps) { + return React.createElement( + 'button', + { + type: 'button', + onClick: () => + onManifestChange?.(initialProjectPath ?? '', updatedManifest), + }, + '同步最新 manifest', + ); + } + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '实时清单项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return initialManifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: (args?.resources as Array<{ resourceId: string }>).map( + (resource) => resource.resourceId, + ), + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-manifest-project', + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-manifest-project', + mode: 'dependency', + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + render( + React.createElement(WorkspaceLauncherShell, { + currentUser: testAuthUser, + initialView: 'projects', + onLogout: vi.fn(), + ProjectSupervisor: ManifestPushingSupervisor, + }), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + const runButton = await screen.findByRole('tab', { name: '运行' }); + expect(runButton.getAttribute('data-unavailable')).toBe('true'); + expect(screen.queryByRole('button', { name: /live-hero\.png/ })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' })); + + expect( + await screen.findByRole('button', { name: /live-hero\.png/ }), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull(); + expect(runButton.getAttribute('data-unavailable')).toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_resource_graph', + expect.objectContaining({ + resources: expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:live-hero', + manifestAssetId: 'live-hero', + }), + expect.objectContaining({ + resourceId: 'version:version-live-1', + }), + ]), + }), + ); + }); + }); + it('starts from the client home and opens a project in the same window', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 8dbd13a61..d0e275420 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -929,6 +929,20 @@ export function registerProjectWorkbenchFoundationTests() { card.textContent?.includes('美术资源计划 Agent 文本回执'), ); expect(restoredCard?.getAttribute('aria-pressed')).toBe('true'); + expect(document.activeElement).toBe(restoredCard); + fireEvent.click(restoredCard!); + expect( + screen.getByRole('region', { + name: '美术资源计划 Agent 文本回执', + }), + ).not.toBeNull(); + fireEvent.keyDown(window, { key: 'Escape' }); + const escapeRestoredCard = screen + .getAllByTitle('打开资源详情') + .find((card) => + card.textContent?.includes('美术资源计划 Agent 文本回执'), + ); + expect(document.activeElement).toBe(escapeRestoredCard); }); it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => { @@ -1330,6 +1344,14 @@ export function registerProjectWorkbenchFoundationTests() { overlay.querySelectorAll('[data-edge-kind="task-flow"]'), ).toHaveLength(0); }); + const dependencyCanvas = screen.getByLabelText('资源依赖视图'); + const descriptionId = dependencyCanvas.getAttribute('aria-describedby'); + expect(descriptionId).not.toBeNull(); + const relationshipDescription = document.getElementById(descriptionId!); + expect(relationshipDescription?.textContent).toContain( + 'ui-dependency.json(待视觉验收) 引用 spec-source.json', + ); + expect(overlay.getAttribute('aria-hidden')).toBe('true'); fireEvent.click(screen.getByRole('button', { name: '按类型' })); expect(screen.queryByTestId('resource-dependency-overlay')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts index 92bc6da08..62a91b90f 100644 --- a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -128,7 +128,7 @@ describe('resource dependency graph model', () => { ); }); - it('keeps real producer assignments and audit truncation metadata', () => { + it('fails closed for producer-derived data when the audit tail is truncated', () => { const graph = normalizeProjectResourceGraph( readModel({ resourceIds: ['asset:spec', 'asset:ui', 'asset:derived'], @@ -168,23 +168,38 @@ describe('resource dependency graph model', () => { dependencyDepth: 2, }, ], + taskFlows: [ + { + id: 'flow:spec-ui', + kind: 'task-flow', + sourceTaskId: 'art-director', + targetTaskId: 'design-foundation', + sourceResourceIds: ['asset:spec'], + targetResourceIds: ['asset:ui'], + cyclic: false, + }, + ], + referenceEdges: [ + { + id: 'reference:spec-ui', + kind: 'asset-reference', + sourceResourceId: 'asset:spec', + targetResourceId: 'asset:ui', + cyclic: false, + }, + ], + cyclicTaskIds: ['art-director'], producerMappingTruncated: true, }), ); - expect(graph.producerTaskIdByResourceId).toEqual( - new Map([ - ['asset:spec', 'art-director'], - ['asset:ui', 'design-foundation'], - ]), - ); - expect(graph.dependencyDepthByResourceId).toEqual( - new Map([ - ['asset:spec', 0], - ['asset:ui', 1], - ['asset:derived', 2], - ]), - ); + expect(graph.producerTaskIdByResourceId).toEqual(new Map()); + expect(graph.dependencyDepthByResourceId).toEqual(new Map()); + expect(graph.taskFlows).toEqual([]); + expect(graph.cyclicTaskIds).toEqual(new Set()); + expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([ + 'reference:spec-ui', + ]); expect(graph.producerMappingTruncated).toBe(true); expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({ upstreamResourceIds: new Set(), diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index d94743946..e536d0a38 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -413,7 +413,8 @@ type ProjectAgentMudPointAttribution = { 5. 风险审批和无需审批不能改变运行策略,点击后明确提示尚未开放;严格审批继续使用现有 Runtime 门禁。 6. 不显示伪造泥点、伪造资源完成度、伪造图片或外部浏览器成功提示。 7. 未识别任务产物不进入“项目版本”,只有正式版本 read model 可以生成版本卡;资源显示名称变化不改变资源身份。 -8. 点击任一资源后只替换中央主视窗,右侧对话与底部 Agent 状态栏保持原位;收起或按 Escape 退出后恢复原搜索、布局模式、滚动位置和选中资源。 +8. 点击任一资源后只替换中央主视窗,右侧对话与底部 Agent 状态栏保持原位;收起或按 Escape 退出后恢复原搜索、布局模式、滚动位置、选中资源和触发资源卡键盘焦点。 +9. 当前 Supervisor 运行期间 manifest 新增资产、任务状态、预览状态和正式版本后,工作台无需重开项目即可同步更新资源列表、依赖图输入、运行入口和版本卡;旧项目迟到回调不得覆盖当前项目。 ### 7.2 P1 资源画布布局持久化验收 @@ -427,14 +428,15 @@ type ProjectAgentMudPointAttribution = { ### 7.3 P1 资源依赖关系图验收 -1. dependency 模式显示明亮橙色实线资源引用,并只在同一资源类型分区内显示灰色虚线任务流;跨类型不显示虚线,type 模式没有图层或连线。 +1. dependency 模式显示对画布背景至少 `3:1` 对比度的橙色实线资源引用,并只在同一资源类型分区内显示灰色虚线任务流;跨类型不显示虚线,type 模式没有图层或连线。 2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。 3. 多资源任务依赖按资源类型分区后,各分区只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线或跨分区虚线。 4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。 5. 搜索触发端点过滤;资源点击不改变上下游卡片或任何连线的视觉状态,资源卡指针移动不更新线段,点击与中央聚焦行为不回归。 6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。 7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。 -8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。 +8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。Agent DB 有界读取截断时 producer、task flow 与其派生深度失败关闭,精确 manifest 引用仍可显示。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。 +9. 依赖 SVG 作为装饰层不可聚焦并对辅助技术隐藏;画布通过关联的视觉隐藏文本逐条说明当前可见资源引用和任务流,搜索过滤或模式切换后文本与可见关系同步变化。 ### 7.4 P1 正式项目版本阶段六验收 @@ -442,7 +444,7 @@ type ProjectAgentMudPointAttribution = { 2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。 3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。 4. 版本聚焦态只读展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定,不提供编辑、替换、切换、回滚或运行按钮。 -5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;修改、删除或重排已有版本时写入失败,原 manifest 字节不被覆盖。 +5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。 6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revision;dependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。 ### 7.5 阶段七完整验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8bc135b27..2111a13a5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6001,3 +6001,9 @@ - Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 + +## 2026-08-04 AI 游戏项目 manifest 存储与工作台实时投影 + +- 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。 +- UI 决策:Project Supervisor 持有运行中 manifest 状态并向外层启动器同步完整快照;外层项目上下文继续是工作台投影的唯一输入,只接受当前项目路径的更新,不另建资产、任务或版本平行状态。 +- 依赖图决策:Agent DB 尾部读取一旦截断,审计 producer 及其 task flow / depth 派生失败关闭;manifest 精确资源引用与审计生产者证据分离。SVG 保持装饰性,辅助技术消费画布关联的文本关系列表。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 85a6cb48e..20882e1e9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -18,7 +18,7 @@ - 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。 - 原因:生产机是 Linux/GNU,而本地门禁运行在 BSD userland、Bash 3.2 和 BSD ar;测试桩硬编码 Linux 二进制路径与参数,归档解析器没有去掉 BSD 扩展成员名的尾随 NUL,路径校验也直接比较了未规范化字符串。 -- 处理:维护 marker 使用同目录临时文件加 POSIX `mv -f`;生产部署测试桩在 macOS 忠实模拟 GNU `mv/ln -T` 的“目标不是目录”语义,并按平台选择系统工具;脚本收集服务使用 Bash 3.2 可用的 `while read`;rlib 解析清理 BSD 成员名 NUL;计划文件只规范化系统临时目录别名,仍拒绝其下用户创建的符号链接组件。 +- 处理:维护 marker 使用同目录临时文件加 POSIX `mv -f`,并在替换前拒绝所有符号链接和目录目标,避免 `mv -f` 跟随目录链接把临时文件移入链接目标;生产部署测试桩在 macOS 忠实模拟 GNU `mv/ln -T` 的“目标不是目录”语义,并按平台选择系统工具;脚本收集服务使用 Bash 3.2 可用的 `while read`;rlib 解析清理 BSD 成员名 NUL;计划文件只规范化系统临时目录别名,仍拒绝其下用户创建的符号链接组件。 - 验证:运行 `npm run check:maintenance-page`、`npm run check:production-api-deploy`、`npm run check:server-rs-ddd`、`npm run test -- scripts/spacetime-repair-editor-canvas-resources.test.ts`,并在 Linux CI 保留同一生产脚本语义。 - 关联:`scripts/deploy/maintenance-on.sh`、`scripts/check-maintenance-page.mjs`、`scripts/check-production-api-deploy.mjs`、`scripts/deploy/production-api-deploy.sh`、`scripts/check-module-runtime-artifact.mjs`、`scripts/spacetime-repair-editor-canvas-resources.mjs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 580ed42db..b5492db64 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -858,3 +858,10 @@ game-project/ - 浏览器未发现、临时环境不可建、启动超时或在 WebSocket URL 解析前退出统一分类为 `preview-infrastructure-unavailable`。首个持久 observation 后收束当前 action batch并失败结束 child/root run,禁止继续用 Provider 逐轮规划同一 revision 的重复启动;普通页面/玩法验收失败仍保留为业务失败,不混入基础设施分类。 - game-chat release 在 `CloseRequested / ExitRequested` 前复用 Runner durable idle probe;只要存在 process session、pending/finalization/provider/tool-plan handoff 或非终态 Agent queue/phase,就阻止关闭并提示先完成、暂停或取消。不可撤销的最终 `Exit` 不再作为唯一保护点,Windows Job Object 的 child-owned 安全边界保持不变。 - 规范 Agent 默认推理档覆盖全部 21 个角色:核心规划、生成、设计/美术/代码原型和质量角色使用 `high`,协调与结构化交付使用 `medium`,确定性预览 gate、音频总监和发布策略使用 `low`;显式 `agentLlm..reasoningEffort` 始终最高优先。规范默认由 Runtime resolver 解析,模板与 GUI 初始草稿保持 `agentLlm` 为空,避免默认值被误判成角色独立 LLM 路由;GUI 必须显示每个角色的实际默认档。全局与逐 Agent status/CLI 必须同时显示实际解析后的 reasoning、request timeout、max retries 和 retry backoff,区分运行快照与后来配置。 + +## 2026-08-04 manifest 与工作台一致性收口 + +- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。 +- 嵌入项目工作台的 Project Supervisor 在本地 manifest 状态变化时向启动器外传完整 manifest,并携带来源项目路径。启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。 +- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer 或 task flow。前端收到截断 DTO 时再次清空 producer、task flow、任务环和依赖深度派生结果;只依赖 manifest 唯一外部资源 ID 的精确引用关系继续保留。 +- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦关闭或按 Escape 退出后恢复触发卡片焦点;橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 48914ebf8..31a458bff 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -898,3 +898,7 @@ node scripts/rebind-orphan-work-owners.mjs --in --out - `--out`:写回后的迁移 JSON 输出路径。 - `--dry-run`:只统计回填行数,不写文件。 - `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。 + +## 维护页目标文件安全边界(2026-08-04) + +`scripts/deploy/maintenance-on.sh` 只允许把同目录临时普通文件原子替换到普通文件或尚不存在的 `page.html` / `enabled` 目标。目标只要是符号链接(包括指向目录的链接)或目录,脚本必须在替换前失败,不能跟随链接把临时文件移入链接目标,也不能打印“已进入维护模式”。跨平台实现继续使用 POSIX `mv -f`,安全语义由替换函数的目标类型门禁保证;修改后运行 `bash -n scripts/deploy/maintenance-on.sh` 与 `npm run check:maintenance-page`。 diff --git a/scripts/check-maintenance-page.mjs b/scripts/check-maintenance-page.mjs index 2c199cf4f..9b64607a5 100644 --- a/scripts/check-maintenance-page.mjs +++ b/scripts/check-maintenance-page.mjs @@ -4,11 +4,15 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, statSync, + symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; @@ -141,6 +145,60 @@ function validateRuntimePageLifecycle() { if (missingPage.status === 0 || existsSync(markerFile)) { fail('不存在的 --page-file 必须在创建 marker 前失败。'); } + + const linkedPageTarget = path.join(tempRoot, 'linked-page-target'); + mkdirSync(linkedPageTarget); + symlinkSync( + linkedPageTarget, + runtimePageFile, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const linkedPageEnable = runScript( + onScript, + ['--page-file', sourcePageFile, 'linked page target'], + env, + ); + if (linkedPageEnable.status === 0) { + fail('maintenance-on 必须拒绝指向目录的公告页符号链接。'); + } + if (!lstatSync(runtimePageFile).isSymbolicLink()) { + fail('拒绝公告页符号链接后不得替换链接本身。'); + } + if (readdirSync(linkedPageTarget).length > 0) { + fail('拒绝公告页符号链接后不得把临时文件移入链接目标目录。'); + } + if (existsSync(markerFile)) { + fail('公告页符号链接校验失败时不得创建维护 marker。'); + } + unlinkSync(runtimePageFile); + + const linkedMarkerTarget = path.join(tempRoot, 'linked-marker-target'); + mkdirSync(linkedMarkerTarget); + symlinkSync( + linkedMarkerTarget, + markerFile, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const linkedMarkerEnable = runScript( + onScript, + ['linked marker target'], + env, + ); + if (linkedMarkerEnable.status === 0) { + fail('maintenance-on 必须拒绝指向目录的 marker 符号链接。'); + } + if (!lstatSync(markerFile).isSymbolicLink()) { + fail('拒绝 marker 符号链接后不得替换链接本身。'); + } + if (readdirSync(linkedMarkerTarget).length > 0) { + fail('拒绝 marker 符号链接后不得把临时文件移入链接目标目录。'); + } + if ( + linkedMarkerEnable.stdout.includes('已进入维护模式') || + linkedMarkerEnable.stderr.includes('已进入维护模式') + ) { + fail('marker 符号链接校验失败时不得打印维护模式成功信息。'); + } } finally { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/scripts/deploy/maintenance-on.sh b/scripts/deploy/maintenance-on.sh index 9948b029e..7b26bea28 100644 --- a/scripts/deploy/maintenance-on.sh +++ b/scripts/deploy/maintenance-on.sh @@ -11,7 +11,11 @@ replace_file_atomically() { local source_file="$1" local target_file="$2" - if [[ -d "${target_file}" && ! -L "${target_file}" ]]; then + if [[ -L "${target_file}" ]]; then + echo "[maintenance] 原子替换目标不能是符号链接: ${target_file}" >&2 + exit 1 + fi + if [[ -d "${target_file}" ]]; then echo "[maintenance] 原子替换目标不能是目录: ${target_file}" >&2 exit 1 fi