修复审核发现的并发与工作台边界
拒绝维护目标符号链接并补充回归测试 为 manifest 写入增加跨进程锁与并发不可变校验 实时同步 Supervisor manifest 到资源、任务和版本工作台 在 Agent DB 截断时关闭不完整依赖推导 补齐依赖图文本等价、焦点恢复和对比度门禁 同步更新产品、技术、运维与项目记忆文档
This commit is contained in:
@@ -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<Mutex<()>> = 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<ManifestWriteLock, String> {
|
||||
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<Option<File>, 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<Option<File>, 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<Option<File>, 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<F>(
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -327,7 +327,11 @@ pub(crate) fn build_project_resource_graph(
|
||||
.iter()
|
||||
.map(|asset| (asset.id.clone(), asset))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
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::<String, Vec<String>>::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<_, _>>(),
|
||||
BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() {
|
||||
let manifest = manifest(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof currentProjectContext>['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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -430,6 +430,7 @@ export function useHomeProjectCreation({
|
||||
projectPath,
|
||||
setProjectPath,
|
||||
currentProjectContext,
|
||||
setCurrentProjectContext,
|
||||
activeProjectPreview,
|
||||
setActiveProjectPreview,
|
||||
activeProjectAgentRuntimeSummaries,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const resourceCanvasRef = useRef<HTMLDivElement>(null);
|
||||
const resourceFocusRef = useRef<HTMLElement>(null);
|
||||
const resourceFocusTriggerIdRef = useRef<string | null>(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<HTMLButtonElement>('[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 ? (
|
||||
<div
|
||||
id={dependencyDescriptionId}
|
||||
className="game-resource-dependency-description"
|
||||
>
|
||||
<p>当前资源关系:</p>
|
||||
<ul>
|
||||
{dependencyRelationshipDescriptions.map((description) => (
|
||||
<li key={description}>{description}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="game-resource-canvas-content">
|
||||
{sortMode === 'dependency' ? (
|
||||
<ResourceDependencyOverlay
|
||||
|
||||
+17
-18
@@ -90,10 +90,8 @@ const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
|
||||
string,
|
||||
ProjectResourceTaskFlow
|
||||
>();
|
||||
const emptyReferenceEdgeMap: ReadonlyMap<
|
||||
string,
|
||||
ProjectResourceReferenceEdge
|
||||
> = new Map<string, ProjectResourceReferenceEdge>();
|
||||
const emptyReferenceEdgeMap: ReadonlyMap<string, ProjectResourceReferenceEdge> =
|
||||
new Map<string, ProjectResourceReferenceEdge>();
|
||||
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors =
|
||||
{
|
||||
@@ -126,6 +124,7 @@ function uniqueSorted(values: Iterable<string>) {
|
||||
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<string, string>();
|
||||
const dependencyDepthByResourceId = new Map<string, number>();
|
||||
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);
|
||||
|
||||
@@ -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)/,
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 阶段七完整验收
|
||||
|
||||
@@ -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 保持装饰性,辅助技术消费画布关联的文本关系列表。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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.<id>.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` 的颜色。
|
||||
|
||||
@@ -898,3 +898,7 @@ node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --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`。
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user