修复审核发现的并发与工作台边界

拒绝维护目标符号链接并补充回归测试
为 manifest 写入增加跨进程锁与并发不可变校验
实时同步 Supervisor manifest 到资源、任务和版本工作台
在 Agent DB 截断时关闭不完整依赖推导
补齐依赖图文本等价、焦点恢复和对比度门禁
同步更新产品、技术、运维与项目记忆文档
This commit is contained in:
2026-08-04 18:38:35 +08:00
parent 2e0ef02fda
commit 208ea3e6ff
21 changed files with 781 additions and 53 deletions
@@ -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(
+17
View File
@@ -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,
+14 -2
View File
@@ -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
@@ -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 revisiondependency / 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`
+58
View File
@@ -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 });
}
+5 -1
View File
@@ -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