WIP: 实现资源依赖关系图层 #126
@@ -1,8 +1,8 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { resolve } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -300,16 +300,100 @@ function stopChild(child, signal = 'SIGTERM') {
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessGroupAlive(processGroupId, killImpl = process.kill) {
|
||||
function parseLinuxProcessStat(value) {
|
||||
const commandEnd = value.lastIndexOf(')');
|
||||
if (commandEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const fields = value
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
if (fields.length < 3 || fields[0].length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const processGroupId = Number(fields[2]);
|
||||
if (!Number.isInteger(processGroupId)) {
|
||||
return null;
|
||||
}
|
||||
return { state: fields[0], processGroupId };
|
||||
}
|
||||
|
||||
function readLinuxProcessGroupRunning(
|
||||
processGroupId,
|
||||
{
|
||||
procRoot = '/proc',
|
||||
readdirImpl = readdirSync,
|
||||
readFileImpl = readFileSync,
|
||||
} = {},
|
||||
) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirImpl(procRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let inspectedProcess = false;
|
||||
for (const entry of entries) {
|
||||
const name = typeof entry === 'string' ? entry : entry.name;
|
||||
if (!/^\d+$/.test(name)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== 'string' && !entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = readFileImpl(join(procRoot, name, 'stat'), 'utf8');
|
||||
} catch (error) {
|
||||
// 进程可能在枚举后立刻退出;继续检查同组的其它成员。
|
||||
if (error?.code === 'ENOENT' || error?.code === 'ESRCH') {
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const parsed = parseLinuxProcessStat(stat);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
inspectedProcess = true;
|
||||
if (
|
||||
parsed?.processGroupId === processGroupId &&
|
||||
!['Z', 'X', 'x'].includes(parsed.state)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return inspectedProcess ? false : null;
|
||||
}
|
||||
|
||||
function isProcessGroupRunning(
|
||||
processGroupId,
|
||||
{
|
||||
platform = process.platform,
|
||||
killImpl = process.kill,
|
||||
readLinuxProcessGroup = readLinuxProcessGroupRunning,
|
||||
} = {},
|
||||
) {
|
||||
if (!Number.isInteger(processGroupId)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
killImpl(-processGroupId, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code !== 'ESRCH';
|
||||
}
|
||||
if (platform !== 'linux') {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// Linux 的 kill(-PGID, 0) 会把尚未被容器 PID 1 回收的 zombie 也视为
|
||||
// 存在;zombie 已不能执行代码,不应让有界清理被误判为失败。
|
||||
return readLinuxProcessGroup(processGroupId) ?? true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
|
||||
@@ -415,7 +499,7 @@ async function terminateChildTree(
|
||||
stopChild(child, 'SIGTERM');
|
||||
if (
|
||||
await waitUntil(
|
||||
() => !isProcessGroupAlive(processGroupId, killImpl),
|
||||
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
|
||||
gracefulTimeoutMs,
|
||||
)
|
||||
) {
|
||||
@@ -430,7 +514,7 @@ async function terminateChildTree(
|
||||
}
|
||||
}
|
||||
const stopped = await waitUntil(
|
||||
() => !isProcessGroupAlive(processGroupId, killImpl),
|
||||
() => !isProcessGroupRunning(processGroupId, { platform, killImpl }),
|
||||
forceTimeoutMs,
|
||||
);
|
||||
return { stopped, forced: true };
|
||||
@@ -600,8 +684,10 @@ export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupRunning,
|
||||
preflightExistingVite,
|
||||
readChildFailure,
|
||||
readLinuxProcessGroupRunning,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
|
||||
@@ -1609,6 +1609,24 @@ mod tests {
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "manifest-dag-policy", "测试正式任务图等待")
|
||||
.expect("init manifest DAG policy project");
|
||||
let session_id = resolve_agent_conversation_session_id_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("resolve manifest DAG Supervisor session");
|
||||
append_unique_game_creator_agent_runtime_pending_task(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&session_id,
|
||||
"测试正式任务图等待",
|
||||
"manifest-dag-policy-run",
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("queue trusted autonomous root task");
|
||||
assert!(!autonomous_manifest_dag_in_progress_at(&root).expect("read pending DAG"));
|
||||
|
||||
update_manifest_task_status_at(
|
||||
|
||||
@@ -937,14 +937,16 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() {
|
||||
"missing visual output must fail the current logical task"
|
||||
);
|
||||
|
||||
assert!(schedule_autonomous_game_build_ready_tasks_at(
|
||||
let scheduled_after_failure = schedule_autonomous_game_build_ready_tasks_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
PARENT_RUN_ID,
|
||||
1,
|
||||
)
|
||||
.expect("repeat scheduling after visual failure")
|
||||
.is_empty());
|
||||
.expect("schedule other ready work after visual failure");
|
||||
assert!(scheduled_after_failure
|
||||
.iter()
|
||||
.all(|scheduled| scheduled.state.agent_id != CHILD_ID));
|
||||
let records = read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path(
|
||||
&root, CHILD_ID,
|
||||
))
|
||||
|
||||
+94
-10
@@ -1,6 +1,74 @@
|
||||
use super::*;
|
||||
|
||||
async fn run_after_pending_stack_boundary<T>(
|
||||
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
|
||||
) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
// Debug builds give pending execution, the background main loop, and queue draining large
|
||||
// poll frames. The boxed future keeps that large frame out of its caller before a joined child
|
||||
// task gives it an independent poll boundary. JoinSet still aborts the child if its parent
|
||||
// continuation is dropped.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
tasks.spawn(future);
|
||||
match tasks
|
||||
.join_next()
|
||||
.await
|
||||
.expect("pending continuation task must exist")
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
|
||||
Err(error) => panic!("pending continuation task was cancelled: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_game_creator_agent_background_task_after_pending_stack_boundary(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
runtime: AgentRuntimeState,
|
||||
continuation: AgentRuntimeContinuationContext,
|
||||
) -> AgentBackgroundTaskOutcome {
|
||||
run_after_pending_stack_boundary(Box::pin(async move {
|
||||
run_game_creator_agent_background_task_with_context(
|
||||
root,
|
||||
agent_id,
|
||||
task,
|
||||
runtime,
|
||||
continuation,
|
||||
)
|
||||
.await
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
) {
|
||||
run_after_pending_stack_boundary(Box::pin(async move {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
pending: AgentRuntimePendingToolAction,
|
||||
runtime: AgentRuntimeState,
|
||||
) {
|
||||
run_after_pending_stack_boundary(Box::pin(async move {
|
||||
continue_game_creator_agent_pending_tool_action_within_stack_boundary(
|
||||
root, agent_id, pending, runtime,
|
||||
)
|
||||
.await;
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
mut pending: AgentRuntimePendingToolAction,
|
||||
@@ -61,7 +129,7 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX);
|
||||
continuation.context_stalled = false;
|
||||
continuation.applied_steer_cursor = batch.planned_steer_cursor;
|
||||
let outcome = run_game_creator_agent_background_task_with_context(
|
||||
let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary(
|
||||
root.clone(),
|
||||
agent_id.clone(),
|
||||
pending.task.clone(),
|
||||
@@ -70,7 +138,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -79,7 +150,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
if !has_persisted_terminal_observation
|
||||
&& stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime)
|
||||
{
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if let Err(error) = validate_agent_runtime_pending_context(&root, &runtime, &pending) {
|
||||
@@ -341,14 +413,18 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
&pending,
|
||||
&observation,
|
||||
) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if observation.is_waiting_for_confirmation()
|
||||
&& stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime)
|
||||
{
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if observation.is_waiting_for_confirmation() {
|
||||
@@ -525,7 +601,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
}),
|
||||
);
|
||||
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !auto_execution {
|
||||
@@ -702,7 +779,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
runtime,
|
||||
&format!("恢复 Agent Runtime context bundle 失败:{error}"),
|
||||
);
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -753,7 +833,10 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
runtime,
|
||||
&format!("持久化 Agent Runtime context bundle 失败:{error}"),
|
||||
);
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(
|
||||
root, agent_id,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -787,7 +870,7 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
return;
|
||||
}
|
||||
}
|
||||
let outcome = run_game_creator_agent_background_task_with_context(
|
||||
let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary(
|
||||
root.clone(),
|
||||
agent_id.clone(),
|
||||
pending.task.clone(),
|
||||
@@ -796,7 +879,8 @@ pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, AgentBackgroundTaskOutcome::Finished) {
|
||||
drain_next_game_creator_agent_background_tasks(root, agent_id).await;
|
||||
drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout(
|
||||
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_resource_graph(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
resources: Vec<ProjectResourceGraphNodeInput>,
|
||||
) -> Result<ProjectResourceGraphReadModel, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
enforce_project_auto_permission_policy(&root, "asset.list")?;
|
||||
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
project_path: String,
|
||||
|
||||
@@ -2025,6 +2025,7 @@ mod game_chat_release_client_exit_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -2378,6 +2379,7 @@ fn main() {
|
||||
stop_local_game_preview_if_matches,
|
||||
get_local_game_preview_status,
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
|
||||
@@ -10,6 +10,7 @@ mod export;
|
||||
mod filesystem;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod resource_dependency_graph;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
@@ -20,5 +21,6 @@ pub(crate) use export::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+66
-136
@@ -300,55 +300,36 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi
|
||||
let AgentRuntimeProviderActionBatchPreparation::Ready(valid_batch) = preparation else {
|
||||
panic!("valid autonomous responsibilities must form a ready durable batch");
|
||||
};
|
||||
assert_eq!(valid_batch.actions.len(), 2);
|
||||
assert_eq!(valid_batch.actions.len(), 3);
|
||||
assert!(valid_batch.collaboration_contract.is_some());
|
||||
let valid_batch_id = valid_batch.batch_id.clone();
|
||||
let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test(
|
||||
"创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/index.html"],
|
||||
"直接修改 game/index.html,修复试玩阻塞并重新验证。",
|
||||
&[],
|
||||
);
|
||||
quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([
|
||||
"直接写入 game/index.html 修复试玩问题",
|
||||
"修改后执行静态验证并交付新 revision"
|
||||
]);
|
||||
let mut design_not_read_only = valid_autonomous_initial_responsibility_actions_for_test();
|
||||
design_not_read_only[0].input["task"] = serde_json::json!("直接修改项目并完成首轮策划实现。");
|
||||
design_not_read_only[0].input["acceptanceCriteria"] = serde_json::json!(["直接修改项目文件"]);
|
||||
let mut design_with_artifacts = valid_autonomous_initial_responsibility_actions_for_test();
|
||||
design_with_artifacts[0].input["expectedArtifacts"] =
|
||||
serde_json::json!(["game/game_design.md"]);
|
||||
let mut art_missing_spec = valid_autonomous_initial_responsibility_actions_for_test();
|
||||
art_missing_spec[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]);
|
||||
let mut code_with_artifacts = valid_autonomous_initial_responsibility_actions_for_test();
|
||||
code_with_artifacts[2].input["expectedArtifacts"] = serde_json::json!(["game/index.html"]);
|
||||
|
||||
let invalid_cases = vec![
|
||||
(
|
||||
"code-missing-game-index",
|
||||
"game/index.html",
|
||||
autonomous_initial_responsibility_actions_for_test(
|
||||
"创建可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/main.js"],
|
||||
"只读验收 game/index.html 的可玩性;不要修改任何项目文件。",
|
||||
&[],
|
||||
),
|
||||
"design-not-read-only",
|
||||
"design-director",
|
||||
design_not_read_only,
|
||||
),
|
||||
(
|
||||
"code-read-only",
|
||||
"code-prototype",
|
||||
autonomous_initial_responsibility_actions_for_test(
|
||||
"只读检查 game/index.html,不要修改任何项目文件。",
|
||||
&["game/index.html"],
|
||||
"只读验收 game/index.html 的可玩性;不要修改任何项目文件。",
|
||||
&[],
|
||||
),
|
||||
),
|
||||
(
|
||||
"quality-not-read-only",
|
||||
"quality-review",
|
||||
quality_not_read_only,
|
||||
),
|
||||
(
|
||||
"quality-with-artifacts",
|
||||
"design-with-artifacts",
|
||||
"expectedArtifacts",
|
||||
autonomous_initial_responsibility_actions_for_test(
|
||||
"创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/index.html"],
|
||||
"只读验收 game/index.html 的可玩性;不要修改任何项目文件。",
|
||||
&["game/index.html"],
|
||||
),
|
||||
design_with_artifacts,
|
||||
),
|
||||
("art-missing-spec", "assets/art-spec.png", art_missing_spec),
|
||||
(
|
||||
"code-with-artifacts",
|
||||
"expectedArtifacts",
|
||||
code_with_artifacts,
|
||||
),
|
||||
];
|
||||
for (case_name, expected_error, actions) in invalid_cases {
|
||||
@@ -389,12 +370,7 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi
|
||||
rewrite_autonomous_responsibility_batch_actions_for_test(
|
||||
&root,
|
||||
&mut legacy_v2_batch,
|
||||
autonomous_initial_responsibility_actions_for_test(
|
||||
"创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/index.html"],
|
||||
"直接修改 game/index.html,修复试玩阻塞并重新验证。",
|
||||
&[],
|
||||
),
|
||||
valid_autonomous_initial_responsibility_actions_for_test(),
|
||||
);
|
||||
let legacy_v2_schema = "game-creator-provider-action-batch.v2";
|
||||
legacy_v2_batch.schema_version = legacy_v2_schema.to_string();
|
||||
@@ -600,68 +576,37 @@ async fn autonomous_game_build_repairs_supervisor_failed_playtest_stall_into_mut
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn autonomous_initial_responsibility_actions_for_test(
|
||||
code_task: &str,
|
||||
code_artifacts: &[&str],
|
||||
quality_task: &str,
|
||||
quality_artifacts: &[&str],
|
||||
) -> Vec<AgentRuntimeToolAction> {
|
||||
fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec<AgentRuntimeToolAction> {
|
||||
vec![
|
||||
AgentRuntimeToolAction {
|
||||
tool: "agent.delegate".to_string(),
|
||||
reason: Some("委派程序 Agent 形成可玩入口".to_string()),
|
||||
input: serde_json::json!({
|
||||
"agentId": "code-prototype",
|
||||
"task": code_task,
|
||||
"acceptanceCriteria": [
|
||||
"game/index.html 必须形成可直接试玩的完整入口",
|
||||
"程序交付必须完成当前 revision 的静态验证"
|
||||
],
|
||||
"expectedArtifacts": code_artifacts,
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
}),
|
||||
},
|
||||
AgentRuntimeToolAction {
|
||||
tool: "agent.delegate".to_string(),
|
||||
reason: Some("委派质量 Agent 独立只读验收".to_string()),
|
||||
input: serde_json::json!({
|
||||
"agentId": "quality-review",
|
||||
"task": quality_task,
|
||||
"acceptanceCriteria": [
|
||||
"只读核对可玩性、交互闭环和阻塞问题",
|
||||
"返回可追溯的验收结论,不修改项目文件"
|
||||
],
|
||||
"expectedArtifacts": quality_artifacts,
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
}),
|
||||
},
|
||||
autonomous_initial_leader_responsibility_action_for_test("design-director", &[]),
|
||||
autonomous_initial_leader_responsibility_action_for_test(
|
||||
"art-director",
|
||||
&["assets/art-spec.png"],
|
||||
),
|
||||
autonomous_initial_leader_responsibility_action_for_test("code-director", &[]),
|
||||
]
|
||||
}
|
||||
|
||||
fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec<AgentRuntimeToolAction> {
|
||||
autonomous_initial_responsibility_actions_for_test(
|
||||
"创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/index.html"],
|
||||
"只读验收 game/index.html 的可玩性与交互闭环;不要修改任何项目文件。",
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
fn autonomous_art_director_responsibility_action_for_test(
|
||||
fn autonomous_initial_leader_responsibility_action_for_test(
|
||||
agent_id: &str,
|
||||
expected_artifacts: &[&str],
|
||||
) -> AgentRuntimeToolAction {
|
||||
let read_only = matches!(agent_id, "design-director" | "code-director");
|
||||
AgentRuntimeToolAction {
|
||||
tool: "agent.delegate".to_string(),
|
||||
reason: Some("委派美术总监生成统一视觉规范图".to_string()),
|
||||
reason: Some("建立首批 Leader 专业规划".to_string()),
|
||||
input: serde_json::json!({
|
||||
"agentId": "art-director",
|
||||
"task": "生成项目统一视觉规范图并写入项目资产目录。",
|
||||
"acceptanceCriteria": [
|
||||
"使用画布生成接口产出后续 UI 与图集共用的规范图",
|
||||
"生成结果必须登记为项目本地 icon-spec 资产"
|
||||
],
|
||||
"agentId": agent_id,
|
||||
"task": if read_only {
|
||||
format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目")
|
||||
} else {
|
||||
"生成首轮统一视觉规范图供后续专业 Agent 使用".to_string()
|
||||
},
|
||||
"acceptanceCriteria": if read_only {
|
||||
serde_json::json!(["只读输出专业规划,不得修改项目文件"])
|
||||
} else {
|
||||
serde_json::json!(["生成并登记统一视觉规范图"])
|
||||
},
|
||||
"expectedArtifacts": expected_artifacts,
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
@@ -713,9 +658,7 @@ async fn supervisor_autonomous_initial_art_director_requires_canonical_art_spec_
|
||||
)
|
||||
.expect("start autonomous Supervisor runtime");
|
||||
let mut actions = valid_autonomous_initial_responsibility_actions_for_test();
|
||||
actions.push(autonomous_art_director_responsibility_action_for_test(&[
|
||||
"assets/art-preview.png",
|
||||
]));
|
||||
actions[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]);
|
||||
let plan = supervisor_collaboration_plan_for_test(actions);
|
||||
let revision = read_game_creator_agent_runtime_project_revision(&root)
|
||||
.expect("read art contract project revision");
|
||||
@@ -834,40 +777,21 @@ async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_bef
|
||||
)
|
||||
.expect("start autonomous Supervisor runtime");
|
||||
|
||||
let code_missing_game_index = autonomous_initial_responsibility_actions_for_test(
|
||||
"创建可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/main.js"],
|
||||
"只读验收 game/index.html 的可玩性;不要修改任何项目文件。",
|
||||
&[],
|
||||
);
|
||||
let code_read_only = autonomous_initial_responsibility_actions_for_test(
|
||||
"只读检查 game/index.html,不要修改任何项目文件。",
|
||||
&["game/index.html"],
|
||||
"只读验收 game/index.html 的可玩性;不要修改任何项目文件。",
|
||||
&[],
|
||||
);
|
||||
let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test(
|
||||
"创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/index.html"],
|
||||
"直接修改 game/index.html,修复试玩阻塞并重新验证。",
|
||||
&[],
|
||||
);
|
||||
quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([
|
||||
"直接写入 game/index.html 修复试玩问题",
|
||||
"修改后执行静态验证并交付新 revision"
|
||||
]);
|
||||
let quality_with_artifacts = autonomous_initial_responsibility_actions_for_test(
|
||||
"创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。",
|
||||
&["game/index.html"],
|
||||
"只读验收 game/index.html 的可玩性;不要修改任何项目文件。",
|
||||
&["game/index.html"],
|
||||
);
|
||||
let valid = valid_autonomous_initial_responsibility_actions_for_test();
|
||||
let mut missing_design = valid.clone();
|
||||
missing_design.remove(0);
|
||||
let mut design_not_read_only = valid.clone();
|
||||
design_not_read_only[0].input["task"] = serde_json::json!("直接修改项目完成策划实现");
|
||||
design_not_read_only[0].input["acceptanceCriteria"] = serde_json::json!(["直接修改项目文件"]);
|
||||
let mut art_missing_spec = valid.clone();
|
||||
art_missing_spec[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]);
|
||||
let mut code_with_artifacts = valid.clone();
|
||||
code_with_artifacts[2].input["expectedArtifacts"] = serde_json::json!(["game/index.html"]);
|
||||
let responses = [
|
||||
("code-missing-game-index", &code_missing_game_index),
|
||||
("code-read-only", &code_read_only),
|
||||
("quality-not-read-only", &quality_not_read_only),
|
||||
("quality-with-artifacts", &quality_with_artifacts),
|
||||
("missing-design", &missing_design),
|
||||
("design-not-read-only", &design_not_read_only),
|
||||
("art-missing-spec", &art_missing_spec),
|
||||
("code-with-artifacts", &code_with_artifacts),
|
||||
("valid-responsibilities", &valid),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -904,7 +828,13 @@ async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_bef
|
||||
.await
|
||||
.expect("repair invalid initial responsibilities")
|
||||
.expect("valid initial responsibility plan");
|
||||
assert_eq!(plan.actions, valid);
|
||||
let mut expected_valid = valid.clone();
|
||||
expected_valid.sort_by(|left, right| {
|
||||
left.input["agentId"]
|
||||
.as_str()
|
||||
.cmp(&right.input["agentId"].as_str())
|
||||
});
|
||||
assert_eq!(plan.actions, expected_valid);
|
||||
assert!(plan.response.is_empty());
|
||||
|
||||
let requests = (0..5)
|
||||
|
||||
@@ -5036,24 +5036,36 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
|
||||
.expect("project init");
|
||||
let delegate_function =
|
||||
native_runtime_function_name("agent.delegate").expect("delegate function");
|
||||
let code_arguments = serde_json::json!({
|
||||
"reason": "委派可玩原型实现",
|
||||
let design_arguments = serde_json::json!({
|
||||
"reason": "委派策划 Leader",
|
||||
"input": {
|
||||
"agentId": "code-prototype",
|
||||
"task": "实现可直接试玩的游戏原型",
|
||||
"acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"],
|
||||
"expectedArtifacts": ["game/index.html"],
|
||||
"agentId": "design-director",
|
||||
"task": "只读拆解首轮玩法目标和专业分工,不得修改项目",
|
||||
"acceptanceCriteria": ["只读输出策划规划,不得修改项目文件"],
|
||||
"expectedArtifacts": [],
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let quality_arguments = serde_json::json!({
|
||||
"reason": "委派独立质量评审",
|
||||
let art_arguments = serde_json::json!({
|
||||
"reason": "委派美术 Leader",
|
||||
"input": {
|
||||
"agentId": "quality-review",
|
||||
"task": "只读评审可玩性与闯关闭环,不要修改任何项目文件",
|
||||
"acceptanceCriteria": ["只读给出阻塞试玩的问题和验收结论"],
|
||||
"agentId": "art-director",
|
||||
"task": "生成首轮统一视觉规范图供后续专业 Agent 使用",
|
||||
"acceptanceCriteria": ["生成并登记统一视觉规范图"],
|
||||
"expectedArtifacts": ["assets/art-spec.png"],
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let code_arguments = serde_json::json!({
|
||||
"reason": "委派程序 Leader",
|
||||
"input": {
|
||||
"agentId": "code-director",
|
||||
"task": "只读拆解首轮程序实现边界,不得修改项目",
|
||||
"acceptanceCriteria": ["只读输出程序规划,不得修改项目文件"],
|
||||
"expectedArtifacts": [],
|
||||
"repairOfDelegationId": null,
|
||||
"runId": null
|
||||
@@ -5061,16 +5073,21 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
|
||||
})
|
||||
.to_string();
|
||||
let recovered_response = native_agent_tool_plan_chat_response_with_calls(vec![
|
||||
(
|
||||
"call-autonomous-upstream-400-design",
|
||||
delegate_function.as_str(),
|
||||
design_arguments,
|
||||
),
|
||||
(
|
||||
"call-autonomous-upstream-400-art",
|
||||
delegate_function.as_str(),
|
||||
art_arguments,
|
||||
),
|
||||
(
|
||||
"call-autonomous-upstream-400-code",
|
||||
delegate_function.as_str(),
|
||||
code_arguments,
|
||||
),
|
||||
(
|
||||
"call-autonomous-upstream-400-quality",
|
||||
delegate_function.as_str(),
|
||||
quality_arguments,
|
||||
),
|
||||
]);
|
||||
let (request_notice_sender, request_notice_receiver) = mpsc::channel();
|
||||
let base_url = spawn_mock_llm_upstream_400_then_raw_response(
|
||||
@@ -5156,7 +5173,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
|
||||
assert!(request_notice_receiver
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.is_err());
|
||||
assert_eq!(plan.actions.len(), 2);
|
||||
assert_eq!(plan.actions.len(), 3);
|
||||
assert!(plan
|
||||
.actions
|
||||
.iter()
|
||||
|
||||
@@ -707,7 +707,7 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() {
|
||||
));
|
||||
|
||||
let scheduled =
|
||||
schedule_game_creator_agent_ready_tasks_at(&root, 0).expect("schedule ready tasks");
|
||||
schedule_game_creator_agent_ready_tasks_at(&root, 1).expect("schedule one ready task");
|
||||
assert_eq!(scheduled.len(), 1);
|
||||
assert_eq!(scheduled[0].state.agent_id, "design-director");
|
||||
assert_eq!(scheduled[0].state.source, "agent-ready-task-scheduler");
|
||||
|
||||
@@ -3871,8 +3871,6 @@ iframe.preview-frame {
|
||||
|
||||
.game-resource-canvas {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
@@ -3882,7 +3880,96 @@ iframe.preview-frame {
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
.game-resource-canvas-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.game-resource-dependency-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge,
|
||||
.game-resource-dependency-edge path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
stroke-width 140ms ease;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--reference {
|
||||
stroke: #d96f3d;
|
||||
stroke-width: 2.25px;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task path {
|
||||
stroke: #918b87;
|
||||
stroke-width: 1.4px;
|
||||
stroke-dasharray: 4 7;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task .game-resource-dependency-trunk {
|
||||
stroke-width: 1.7px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task .game-resource-dependency-branch {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge.is-highlighted {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--reference.is-highlighted {
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task.is-highlighted path {
|
||||
opacity: 1;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task.is-highlighted .game-resource-dependency-trunk {
|
||||
stroke-width: 2.4px;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge.is-dimmed {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge.is-cyclic,
|
||||
.game-resource-dependency-edge.is-cyclic path {
|
||||
stroke-dashoffset: 4;
|
||||
}
|
||||
|
||||
.game-resource-dependency-marker--reference path {
|
||||
fill: #d96f3d;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.game-resource-dependency-marker--task path {
|
||||
fill: #918b87;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.game-resource-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 620px;
|
||||
@@ -3978,6 +4065,15 @@ iframe.preview-frame {
|
||||
0 0 0 2px rgb(213 123 81 / 18%);
|
||||
}
|
||||
|
||||
.game-resource-card.is-relation-upstream,
|
||||
.game-resource-card.is-relation-downstream,
|
||||
.game-resource-card.is-relation-both {
|
||||
border-color: #d87342;
|
||||
box-shadow:
|
||||
0 8px 22px rgb(195 105 62 / 18%),
|
||||
0 0 0 2px rgb(216 115 66 / 14%);
|
||||
}
|
||||
|
||||
.game-resource-card-icon {
|
||||
display: grid;
|
||||
grid-row: 1 / 4;
|
||||
|
||||
+630
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+257
@@ -0,0 +1,257 @@
|
||||
export type ProjectResourceGraphNodeInput = {
|
||||
resourceId: string;
|
||||
manifestAssetId: string | null;
|
||||
producerTaskId: string | null;
|
||||
};
|
||||
|
||||
export type ProjectResourceReferenceEdge = {
|
||||
id: string;
|
||||
kind: 'asset-reference';
|
||||
sourceResourceId: string;
|
||||
targetResourceId: string;
|
||||
cyclic: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceTaskFlow = {
|
||||
id: string;
|
||||
kind: 'task-flow';
|
||||
sourceTaskId: string;
|
||||
targetTaskId: string;
|
||||
sourceResourceIds: string[];
|
||||
targetResourceIds: string[];
|
||||
cyclic: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceConnectionIndexDto = {
|
||||
resourceId: string;
|
||||
upstreamReferenceResourceIds: string[];
|
||||
downstreamReferenceResourceIds: string[];
|
||||
referenceEdgeIds: string[];
|
||||
taskFlowIds: string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceProducerAssignment = {
|
||||
resourceId: string;
|
||||
taskId: string;
|
||||
dependencyDepth: number;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphReadModel = {
|
||||
resourceIds: string[];
|
||||
referenceEdges: ProjectResourceReferenceEdge[];
|
||||
taskFlows: ProjectResourceTaskFlow[];
|
||||
connectionIndex: ProjectResourceConnectionIndexDto[];
|
||||
producerAssignments: ProjectResourceProducerAssignment[];
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: string[];
|
||||
cyclicTaskIds: string[];
|
||||
producerMappingTruncated: boolean;
|
||||
};
|
||||
|
||||
type ProjectResourceConnectionIndex = {
|
||||
upstreamReferenceResourceIds: ReadonlySet<string>;
|
||||
downstreamReferenceResourceIds: ReadonlySet<string>;
|
||||
referenceEdgeIds: ReadonlySet<string>;
|
||||
taskFlowIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraph = {
|
||||
resourceIds: ReadonlySet<string>;
|
||||
referenceEdges: ProjectResourceReferenceEdge[];
|
||||
referenceEdgeById: ReadonlyMap<string, ProjectResourceReferenceEdge>;
|
||||
taskFlows: ProjectResourceTaskFlow[];
|
||||
taskFlowById: ReadonlyMap<string, ProjectResourceTaskFlow>;
|
||||
connectionIndex: ReadonlyMap<string, ProjectResourceConnectionIndex>;
|
||||
producerTaskIdByResourceId: ReadonlyMap<string, string>;
|
||||
dependencyDepthByResourceId: ReadonlyMap<string, number>;
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: ReadonlySet<string>;
|
||||
cyclicTaskIds: ReadonlySet<string>;
|
||||
producerMappingTruncated: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphNeighbors = {
|
||||
upstreamResourceIds: ReadonlySet<string>;
|
||||
downstreamResourceIds: ReadonlySet<string>;
|
||||
connectedEdgeIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const emptyStringSet: ReadonlySet<string> = new Set<string>();
|
||||
const emptyStringMap: ReadonlyMap<string, string> = new Map<string, string>();
|
||||
const emptyNumberMap: ReadonlyMap<string, number> = new Map<string, number>();
|
||||
const emptyConnectionMap: ReadonlyMap<string, ProjectResourceConnectionIndex> =
|
||||
new Map<string, ProjectResourceConnectionIndex>();
|
||||
const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
|
||||
string,
|
||||
ProjectResourceTaskFlow
|
||||
>();
|
||||
const emptyReferenceEdgeMap: ReadonlyMap<
|
||||
string,
|
||||
ProjectResourceReferenceEdge
|
||||
> = new Map<string, ProjectResourceReferenceEdge>();
|
||||
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors =
|
||||
{
|
||||
upstreamResourceIds: emptyStringSet,
|
||||
downstreamResourceIds: emptyStringSet,
|
||||
connectedEdgeIds: emptyStringSet,
|
||||
};
|
||||
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = {
|
||||
resourceIds: emptyStringSet,
|
||||
referenceEdges: [],
|
||||
referenceEdgeById: emptyReferenceEdgeMap,
|
||||
taskFlows: [],
|
||||
taskFlowById: emptyTaskFlowMap,
|
||||
connectionIndex: emptyConnectionMap,
|
||||
producerTaskIdByResourceId: emptyStringMap,
|
||||
dependencyDepthByResourceId: emptyNumberMap,
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: emptyStringSet,
|
||||
cyclicTaskIds: emptyStringSet,
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
|
||||
function uniqueSorted(values: Iterable<string>) {
|
||||
return Array.from(new Set(values)).sort((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeProjectResourceGraph(
|
||||
readModel: ProjectResourceGraphReadModel,
|
||||
): ProjectResourceGraph {
|
||||
const resourceIds = new Set(uniqueSorted(readModel.resourceIds));
|
||||
const referenceEdges = readModel.referenceEdges
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.kind === 'asset-reference' &&
|
||||
resourceIds.has(edge.sourceResourceId) &&
|
||||
resourceIds.has(edge.targetResourceId),
|
||||
)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id));
|
||||
const taskFlows = readModel.taskFlows
|
||||
.flatMap((flow) => {
|
||||
if (flow.kind !== 'task-flow') {
|
||||
return [];
|
||||
}
|
||||
const sourceResourceIds = uniqueSorted(
|
||||
flow.sourceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
);
|
||||
const targetResourceIds = uniqueSorted(
|
||||
flow.targetResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
);
|
||||
return sourceResourceIds.length > 0 && targetResourceIds.length > 0
|
||||
? [{ ...flow, sourceResourceIds, targetResourceIds }]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const taskFlowIds = new Set(taskFlows.map((flow) => flow.id));
|
||||
const connectionIndex = new Map<string, ProjectResourceConnectionIndex>();
|
||||
for (const index of readModel.connectionIndex) {
|
||||
if (!resourceIds.has(index.resourceId)) {
|
||||
continue;
|
||||
}
|
||||
connectionIndex.set(index.resourceId, {
|
||||
upstreamReferenceResourceIds: new Set(
|
||||
index.upstreamReferenceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
downstreamReferenceResourceIds: new Set(
|
||||
index.downstreamReferenceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
referenceEdgeIds: new Set(
|
||||
index.referenceEdgeIds.filter((edgeId) =>
|
||||
referenceEdgeIds.has(edgeId),
|
||||
),
|
||||
),
|
||||
taskFlowIds: new Set(
|
||||
index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)),
|
||||
),
|
||||
});
|
||||
}
|
||||
const producerTaskIdByResourceId = new Map<string, string>();
|
||||
const dependencyDepthByResourceId = new Map<string, number>();
|
||||
for (const assignment of readModel.producerAssignments) {
|
||||
if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) {
|
||||
continue;
|
||||
}
|
||||
producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId);
|
||||
if (
|
||||
Number.isSafeInteger(assignment.dependencyDepth) &&
|
||||
assignment.dependencyDepth >= 0
|
||||
) {
|
||||
dependencyDepthByResourceId.set(
|
||||
assignment.resourceId,
|
||||
assignment.dependencyDepth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resourceIds,
|
||||
referenceEdges,
|
||||
referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])),
|
||||
taskFlows,
|
||||
taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])),
|
||||
connectionIndex,
|
||||
producerTaskIdByResourceId,
|
||||
dependencyDepthByResourceId,
|
||||
unresolvedReferenceResourceIds: uniqueSorted(
|
||||
readModel.unresolvedReferenceResourceIds,
|
||||
),
|
||||
cyclicResourceIds: new Set(
|
||||
readModel.cyclicResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
cyclicTaskIds: new Set(readModel.cyclicTaskIds),
|
||||
producerMappingTruncated: Boolean(readModel.producerMappingTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectResourceGraphNeighbors(
|
||||
graph: ProjectResourceGraph,
|
||||
resourceId: string | null,
|
||||
): ProjectResourceGraphNeighbors {
|
||||
if (!resourceId || !graph.resourceIds.has(resourceId)) {
|
||||
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
|
||||
}
|
||||
const index = graph.connectionIndex.get(resourceId);
|
||||
if (!index) {
|
||||
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
|
||||
}
|
||||
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);
|
||||
if (!flow) {
|
||||
continue;
|
||||
}
|
||||
if (flow.targetResourceIds.includes(resourceId)) {
|
||||
flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id));
|
||||
connectedEdgeIds.add(flow.id);
|
||||
}
|
||||
if (flow.sourceResourceIds.includes(resourceId)) {
|
||||
flow.targetResourceIds.forEach((id) => downstreamResourceIds.add(id));
|
||||
connectedEdgeIds.add(flow.id);
|
||||
}
|
||||
}
|
||||
|
||||
upstreamResourceIds.delete(resourceId);
|
||||
downstreamResourceIds.delete(resourceId);
|
||||
return { upstreamResourceIds, downstreamResourceIds, connectedEdgeIds };
|
||||
}
|
||||
+103
-20
@@ -81,16 +81,58 @@ function layoutMatchesScope(
|
||||
return layout.projectId === scope.projectId && layout.mode === scope.mode;
|
||||
}
|
||||
|
||||
function positionsEqual(
|
||||
left: ProjectResourceCanvasLayout['positions'],
|
||||
right: ProjectResourceCanvasLayout['positions'],
|
||||
) {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((position, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
other?.resourceId === position.resourceId &&
|
||||
other.section === position.section &&
|
||||
other.x === position.x &&
|
||||
other.y === position.y &&
|
||||
other.manuallyPlaced === position.manuallyPlaced
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileLayout(
|
||||
source: ProjectResourceCanvasLayout,
|
||||
resources: ResourceCanvasItem[],
|
||||
rederiveAutomaticPositions: boolean,
|
||||
) {
|
||||
if (!rederiveAutomaticPositions) {
|
||||
return reconcileResourceCanvasLayout(source, resources);
|
||||
}
|
||||
const manualSource = {
|
||||
...source,
|
||||
positions: source.positions.filter((position) => position.manuallyPlaced),
|
||||
};
|
||||
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
|
||||
return {
|
||||
layout: reconciled.layout,
|
||||
changed: !positionsEqual(source.positions, reconciled.layout.positions),
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode,
|
||||
resources,
|
||||
initializationReady = true,
|
||||
rederiveAutomaticPositions = false,
|
||||
}: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
resources: ResourceCanvasItem[];
|
||||
initializationReady?: boolean;
|
||||
rederiveAutomaticPositions?: boolean;
|
||||
}) {
|
||||
const scopeKey = createScopeKey(projectPath, projectId, mode);
|
||||
const resourceSignature = useMemo(
|
||||
@@ -98,13 +140,18 @@ export function useProjectResourceCanvasLayout({
|
||||
[resources],
|
||||
);
|
||||
const fallback = useMemo(
|
||||
() =>
|
||||
reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout(projectId, mode),
|
||||
resources,
|
||||
).layout,
|
||||
[mode, projectId, resources],
|
||||
);
|
||||
() => {
|
||||
const empty = createEmptyResourceCanvasLayout(projectId, mode);
|
||||
return initializationReady
|
||||
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
|
||||
: empty;
|
||||
}, [
|
||||
initializationReady,
|
||||
mode,
|
||||
projectId,
|
||||
rederiveAutomaticPositions,
|
||||
resources,
|
||||
]);
|
||||
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
|
||||
const [notice, setNotice] = useState<LayoutNotice>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -151,9 +198,10 @@ export function useProjectResourceCanvasLayout({
|
||||
if (scope.epoch !== scopeEpoch) {
|
||||
return;
|
||||
}
|
||||
let next = reconcileResourceCanvasLayout(
|
||||
let next = reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).layout;
|
||||
for (const intent of writeQueueRef.current) {
|
||||
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
|
||||
@@ -168,7 +216,7 @@ export function useProjectResourceCanvasLayout({
|
||||
}
|
||||
applyLayout(next);
|
||||
},
|
||||
[applyLayout],
|
||||
[applyLayout, rederiveAutomaticPositions],
|
||||
);
|
||||
|
||||
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
|
||||
@@ -229,9 +277,10 @@ export function useProjectResourceCanvasLayout({
|
||||
return;
|
||||
}
|
||||
|
||||
const reconciled = reconcileResourceCanvasLayout(
|
||||
const reconciled = reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
);
|
||||
if (intent.kind === 'resources' && !reconciled.changed) {
|
||||
removeWriteIntent(intent);
|
||||
@@ -330,7 +379,11 @@ export function useProjectResourceCanvasLayout({
|
||||
setNotice('布局已保存');
|
||||
}
|
||||
if (
|
||||
reconcileResourceCanvasLayout(result.layout, resourcesRef.current)
|
||||
reconcileLayout(
|
||||
result.layout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
)
|
||||
.changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(currentScope.epoch);
|
||||
@@ -346,9 +399,10 @@ export function useProjectResourceCanvasLayout({
|
||||
queued.scopeEpoch !== currentScope.epoch ||
|
||||
queued.kind !== 'manual',
|
||||
);
|
||||
const needsResourceSync = reconcileResourceCanvasLayout(
|
||||
const needsResourceSync = reconcileLayout(
|
||||
result.layout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).changed;
|
||||
const nextRetry =
|
||||
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
|
||||
@@ -435,9 +489,18 @@ export function useProjectResourceCanvasLayout({
|
||||
writeQueueRef.current = [];
|
||||
activeWriteIntentRef.current = null;
|
||||
redragRequiredScopeEpochRef.current = null;
|
||||
const initialFallback = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout(projectId, mode),
|
||||
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
|
||||
if (!initializationReady) {
|
||||
persistedLayoutRef.current = emptyLayout;
|
||||
applyLayout(emptyLayout);
|
||||
setNotice('');
|
||||
setSaving(false);
|
||||
return undefined;
|
||||
}
|
||||
const initialFallback = reconcileLayout(
|
||||
emptyLayout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).layout;
|
||||
persistedLayoutRef.current = initialFallback;
|
||||
applyLayout(initialFallback);
|
||||
@@ -469,7 +532,11 @@ export function useProjectResourceCanvasLayout({
|
||||
persistedLayoutRef.current = loaded;
|
||||
initializedScopeEpochRef.current = epoch;
|
||||
if (
|
||||
reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed
|
||||
reconcileLayout(
|
||||
loaded,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(epoch);
|
||||
}
|
||||
@@ -491,10 +558,12 @@ export function useProjectResourceCanvasLayout({
|
||||
};
|
||||
}, [
|
||||
applyLayout,
|
||||
initializationReady,
|
||||
mode,
|
||||
projectId,
|
||||
projectPath,
|
||||
rebuildOptimisticLayout,
|
||||
rederiveAutomaticPositions,
|
||||
scopeKey,
|
||||
]);
|
||||
|
||||
@@ -502,25 +571,34 @@ export function useProjectResourceCanvasLayout({
|
||||
const scope = scopeRef.current;
|
||||
if (
|
||||
scope.key !== scopeKey ||
|
||||
!initializationReady ||
|
||||
initializedScopeEpochRef.current !== scope.epoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reconciledCurrent = reconcileResourceCanvasLayout(
|
||||
const reconciledCurrent = reconcileLayout(
|
||||
layoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
);
|
||||
applyLayout(reconciledCurrent.layout);
|
||||
if (
|
||||
window.__TAURI__?.core?.invoke &&
|
||||
reconcileResourceCanvasLayout(
|
||||
reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(scope.epoch);
|
||||
}
|
||||
}, [applyLayout, resourceSignature, scopeKey]);
|
||||
}, [
|
||||
applyLayout,
|
||||
initializationReady,
|
||||
rederiveAutomaticPositions,
|
||||
resourceSignature,
|
||||
scopeKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
@@ -544,7 +622,11 @@ export function useProjectResourceCanvasLayout({
|
||||
y: number,
|
||||
) => {
|
||||
const scope = scopeRef.current;
|
||||
if (scope.key !== scopeKey) {
|
||||
if (
|
||||
scope.key !== scopeKey ||
|
||||
!initializationReady ||
|
||||
initializedScopeEpochRef.current !== scope.epoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const queuedIntent = writeQueueRef.current.find(
|
||||
@@ -580,10 +662,11 @@ export function useProjectResourceCanvasLayout({
|
||||
setSaving(true);
|
||||
pumpWritesRef.current();
|
||||
},
|
||||
[applyLayout, scopeKey],
|
||||
[applyLayout, initializationReady, scopeKey],
|
||||
);
|
||||
|
||||
const scopeMatches =
|
||||
initializationReady &&
|
||||
scopeRef.current.key === scopeKey &&
|
||||
layout.projectId === projectId &&
|
||||
layout.mode === mode;
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
normalizeProjectResourceGraph,
|
||||
type ProjectResourceGraph,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
import {
|
||||
ResourceDependencyOverlay,
|
||||
type ResourceDependencyOverlayHandle,
|
||||
} from '../src/view/project-development/ResourceDependencyOverlay';
|
||||
|
||||
function position(
|
||||
resourceId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): ProjectResourceCanvasPosition {
|
||||
return {
|
||||
resourceId,
|
||||
section: 'art',
|
||||
x,
|
||||
y,
|
||||
manuallyPlaced: false,
|
||||
};
|
||||
}
|
||||
|
||||
function graphFixture() {
|
||||
const referenceId = 'asset-reference:["source:one","target:one"]';
|
||||
const selfReferenceId = 'asset-reference:["unrelated","unrelated"]';
|
||||
const flowId = 'task-flow:["source-task","target-task"]';
|
||||
const resourceIds = [
|
||||
'source:one',
|
||||
'source:two',
|
||||
'source:three',
|
||||
'target:one',
|
||||
'target:two',
|
||||
'target:three',
|
||||
'unrelated',
|
||||
];
|
||||
const readModel: ProjectResourceGraphReadModel = {
|
||||
resourceIds,
|
||||
referenceEdges: [
|
||||
{
|
||||
id: referenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'source:one',
|
||||
targetResourceId: 'target:one',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: selfReferenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'unrelated',
|
||||
targetResourceId: 'unrelated',
|
||||
cyclic: true,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: flowId,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'source-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['source:one', 'source:two', 'source:three'],
|
||||
targetResourceIds: ['target:one', 'target:two', 'target:three'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
connectionIndex: resourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds:
|
||||
resourceId === 'target:one'
|
||||
? ['source:one']
|
||||
: resourceId === 'unrelated'
|
||||
? ['unrelated']
|
||||
: [],
|
||||
downstreamReferenceResourceIds:
|
||||
resourceId === 'source:one'
|
||||
? ['target:one']
|
||||
: resourceId === 'unrelated'
|
||||
? ['unrelated']
|
||||
: [],
|
||||
referenceEdgeIds:
|
||||
resourceId === 'source:one' || resourceId === 'target:one'
|
||||
? [referenceId]
|
||||
: resourceId === 'unrelated'
|
||||
? [selfReferenceId]
|
||||
: [],
|
||||
taskFlowIds: resourceId === 'unrelated' ? [] : [flowId],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: ['unrelated'],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
return normalizeProjectResourceGraph(readModel);
|
||||
}
|
||||
|
||||
function OverlayHarness({
|
||||
graph,
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId = null,
|
||||
overlayRef,
|
||||
}: {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: ProjectResourceCanvasPosition[];
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId?: string | null;
|
||||
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>;
|
||||
}) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement('div', { 'data-resource-section-plane': 'art' }),
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
ref: overlayRef,
|
||||
graph,
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function overlayView(
|
||||
graph: ProjectResourceGraph,
|
||||
positions: ProjectResourceCanvasPosition[],
|
||||
visibleResourceIds: ReadonlySet<string>,
|
||||
selectedResourceId: string | null = null,
|
||||
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>,
|
||||
) {
|
||||
return React.createElement(OverlayHarness, {
|
||||
graph,
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
overlayRef,
|
||||
});
|
||||
}
|
||||
|
||||
describe('ResourceDependencyOverlay', () => {
|
||||
it('renders exact references and one aggregated task trunk without cartesian paths', async () => {
|
||||
const graph = graphFixture();
|
||||
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
|
||||
position(resourceId, (index % 3) * 220, Math.floor(index / 3) * 120),
|
||||
);
|
||||
render(overlayView(graph, positions, new Set(graph.resourceIds)));
|
||||
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]');
|
||||
expect(taskFlow).not.toBeNull();
|
||||
const taskPaths = Array.from(
|
||||
taskFlow?.querySelectorAll<SVGPathElement>('path') ?? [],
|
||||
);
|
||||
expect(taskPaths).toHaveLength(7);
|
||||
expect(
|
||||
taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')),
|
||||
).toBe(false);
|
||||
expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3);
|
||||
expect(
|
||||
overlay
|
||||
.querySelector('marker[id$="-task-flow-arrow"]')
|
||||
?.getAttribute('markerUnits'),
|
||||
).toBe('userSpaceOnUse');
|
||||
});
|
||||
|
||||
it('filters hidden endpoints and updates path geometry when positions change', async () => {
|
||||
const graph = graphFixture();
|
||||
const positions = [
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 240, 0),
|
||||
];
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
const view = render(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(['source:one', 'target:one']),
|
||||
null,
|
||||
overlayRef,
|
||||
),
|
||||
);
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const firstPath = await waitFor(() => {
|
||||
const path = overlay.querySelector<SVGPathElement>(
|
||||
'[data-edge-kind="asset-reference"]',
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
return path?.getAttribute('d');
|
||||
});
|
||||
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'source:one',
|
||||
x: 80,
|
||||
y: 40,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay
|
||||
.querySelector('[data-edge-kind="asset-reference"]')
|
||||
?.getAttribute('d'),
|
||||
).not.toBe(firstPath),
|
||||
);
|
||||
|
||||
view.rerender(overlayView(graph, positions, new Set(['target:one'])));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelector('[data-edge-kind="asset-reference"]'),
|
||||
).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the section origin when updating a dragged path', async () => {
|
||||
const getBoundingClientRect = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function () {
|
||||
const isPlane = this.hasAttribute('data-resource-section-plane');
|
||||
return {
|
||||
x: isPlane ? 160 : 20,
|
||||
y: isPlane ? 90 : 10,
|
||||
left: isPlane ? 160 : 20,
|
||||
top: isPlane ? 90 : 10,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const graph = graphFixture();
|
||||
const positions = [
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 240, 0),
|
||||
];
|
||||
const visible = new Set(['source:one', 'target:one']);
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
render(overlayView(graph, positions, visible, null, overlayRef));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const selector = '[data-edge-kind="asset-reference"]';
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
||||
'M 320 126',
|
||||
),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'source:one',
|
||||
x: 80,
|
||||
y: 40,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
||||
'M 400 166',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
getBoundingClientRect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => {
|
||||
const graph = graphFixture();
|
||||
const view = render(
|
||||
overlayView(
|
||||
graph,
|
||||
[position('unrelated', 24, 32)],
|
||||
new Set(['unrelated']),
|
||||
),
|
||||
);
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const selfLoopSelector =
|
||||
'[data-edge-kind="asset-reference"]' +
|
||||
'[data-source-resource-id="unrelated"]' +
|
||||
'[data-target-resource-id="unrelated"]';
|
||||
const selfLoop = await waitFor(() => {
|
||||
const path = overlay.querySelector<SVGPathElement>(selfLoopSelector);
|
||||
expect(path).not.toBeNull();
|
||||
return path as SVGPathElement;
|
||||
});
|
||||
|
||||
expect(selfLoop.getAttribute('data-cyclic')).toBe('true');
|
||||
expect(selfLoop.getAttribute('data-self-loop')).toBe('true');
|
||||
expect(selfLoop.getAttribute('d')).toBe(
|
||||
'M 204 96 C 260 96, 260 60, 204 60',
|
||||
);
|
||||
expect(selfLoop.getAttribute('marker-end')).toContain(
|
||||
'asset-reference-arrow',
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
[position('unrelated', 84, 48)],
|
||||
new Set(['unrelated']),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(selfLoop.getAttribute('d')).toBe(
|
||||
'M 264 112 C 320 112, 320 76, 264 76',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('highlights direct edges and dims unrelated cyclic edges for a selection', async () => {
|
||||
const graph = graphFixture();
|
||||
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
|
||||
position(resourceId, index * 200, 0),
|
||||
);
|
||||
render(
|
||||
overlayView(graph, positions, new Set(graph.resourceIds), 'target:one'),
|
||||
);
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('.is-highlighted').length,
|
||||
).toBeGreaterThan(0),
|
||||
);
|
||||
const unrelated = Array.from(
|
||||
overlay.querySelectorAll<SVGElement>(
|
||||
'[data-edge-kind="asset-reference"]',
|
||||
),
|
||||
).find((edge) => edge.getAttribute('data-cyclic') === 'true');
|
||||
expect(unrelated?.classList.contains('is-dimmed')).toBe(true);
|
||||
});
|
||||
|
||||
it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => {
|
||||
const resourceIds = Array.from(
|
||||
{ length: 4096 },
|
||||
(_, index) => `resource:${index}`,
|
||||
);
|
||||
const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({
|
||||
id: `flow:${index}:${index + 1}`,
|
||||
kind: 'task-flow' as const,
|
||||
sourceTaskId: `task:${index}`,
|
||||
targetTaskId: `task:${index + 1}`,
|
||||
sourceResourceIds: [`resource:${index}`],
|
||||
targetResourceIds: [resourceId],
|
||||
cyclic: false,
|
||||
}));
|
||||
const graph = normalizeProjectResourceGraph({
|
||||
resourceIds,
|
||||
referenceEdges: [],
|
||||
taskFlows,
|
||||
connectionIndex: resourceIds.map((resourceId, index) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [
|
||||
...(index > 0 ? [`flow:${index - 1}:${index}`] : []),
|
||||
...(index < resourceIds.length - 1
|
||||
? [`flow:${index}:${index + 1}`]
|
||||
: []),
|
||||
],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
});
|
||||
const positions = [
|
||||
position('resource:2047', 0, 0),
|
||||
position('resource:2048', 220, 0),
|
||||
position('resource:2049', 440, 0),
|
||||
];
|
||||
const visible = new Set(positions.map(({ resourceId }) => resourceId));
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
render(overlayView(graph, positions, visible, null, overlayRef));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
const setAttribute = vi.spyOn(
|
||||
SVGElement.prototype,
|
||||
'setAttribute',
|
||||
);
|
||||
try {
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'resource:2048',
|
||||
x: 260,
|
||||
y: 32,
|
||||
}),
|
||||
);
|
||||
const geometryUpdates = setAttribute.mock.calls.filter(
|
||||
([name]) => name === 'd',
|
||||
);
|
||||
expect(geometryUpdates).toHaveLength(6);
|
||||
} finally {
|
||||
setAttribute.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('disconnects layout observers when the SVG layer is destroyed', () => {
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
class TestResizeObserver {
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
|
||||
observe = observe;
|
||||
unobserve = vi.fn();
|
||||
disconnect = disconnect;
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
||||
try {
|
||||
const graph = graphFixture();
|
||||
const positions = Array.from(graph.resourceIds).map(
|
||||
(resourceId, index) => position(resourceId, index * 200, 0),
|
||||
);
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
const view = render(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
null,
|
||||
overlayRef,
|
||||
),
|
||||
);
|
||||
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'source:one',
|
||||
x: 32,
|
||||
y: 24,
|
||||
}),
|
||||
);
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -801,7 +801,9 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(screen.getByLabelText('项目总控消息').textContent).toContain(
|
||||
'第一行\n第二行\n第三行',
|
||||
);
|
||||
expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('assets/uploads/reference.png'),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath: '/tmp/home-created-game',
|
||||
projectId: 'local-project-draft',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeProjectResourceGraph,
|
||||
projectResourceGraphNeighbors,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
|
||||
function readModel(
|
||||
overrides: Partial<ProjectResourceGraphReadModel> = {},
|
||||
): ProjectResourceGraphReadModel {
|
||||
return {
|
||||
resourceIds: [],
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
connectionIndex: [],
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resource dependency graph model', () => {
|
||||
it('normalizes the Rust read model and filters stale resource endpoints', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['asset:source', 'asset:target'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'valid-reference',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:source',
|
||||
targetResourceId: 'asset:target',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: 'ghost-reference',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:deleted',
|
||||
targetResourceId: 'asset:target',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: 'valid-flow',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'source-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['asset:source', 'asset:deleted'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: 'ghost-flow',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'deleted-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['asset:deleted'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
cyclicResourceIds: ['asset:source', 'asset:deleted'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([
|
||||
'valid-reference',
|
||||
]);
|
||||
expect(graph.taskFlows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'valid-flow',
|
||||
sourceResourceIds: ['asset:source'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
}),
|
||||
]);
|
||||
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source']));
|
||||
});
|
||||
|
||||
it('queries direct reference and aggregated task-flow neighbors from the bounded index', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['source:a', 'source:b', 'target:a', 'target:b'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'reference:a',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'source:a',
|
||||
targetResourceId: 'target:a',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: 'flow:a-b',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'task:a',
|
||||
targetTaskId: 'task:b',
|
||||
sourceResourceIds: ['source:a', 'source:b'],
|
||||
targetResourceIds: ['target:a', 'target:b'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
connectionIndex: [
|
||||
{
|
||||
resourceId: 'target:a',
|
||||
upstreamReferenceResourceIds: ['source:a'],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: ['reference:a'],
|
||||
taskFlowIds: ['flow:a-b'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const neighbors = projectResourceGraphNeighbors(graph, 'target:a');
|
||||
expect(neighbors.upstreamResourceIds).toEqual(
|
||||
new Set(['source:a', 'source:b']),
|
||||
);
|
||||
expect(neighbors.downstreamResourceIds).toEqual(new Set());
|
||||
expect(neighbors.connectedEdgeIds).toEqual(
|
||||
new Set(['reference:a', 'flow:a-b']),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps real producer assignments and audit truncation metadata', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['asset:spec', 'asset:ui'],
|
||||
producerAssignments: [
|
||||
{
|
||||
resourceId: 'asset:spec',
|
||||
taskId: 'art-director',
|
||||
dependencyDepth: 0,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:ui',
|
||||
taskId: 'design-foundation',
|
||||
dependencyDepth: 1,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:deleted',
|
||||
taskId: 'task-1',
|
||||
dependencyDepth: 99,
|
||||
},
|
||||
],
|
||||
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],
|
||||
]),
|
||||
);
|
||||
expect(graph.producerMappingTruncated).toBe(true);
|
||||
expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({
|
||||
upstreamResourceIds: new Set(),
|
||||
downstreamResourceIds: new Set(),
|
||||
connectedEdgeIds: new Set(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureBackend,
|
||||
isProcessGroupRunning,
|
||||
preflightExistingVite,
|
||||
readLinuxProcessGroupRunning,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
@@ -154,6 +156,40 @@ describe('AI 游戏创作启动子进程生命周期', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Linux 进程组探活忽略已退出但尚未回收的 zombie', () => {
|
||||
const stats = new Map([
|
||||
['/proc/4822/stat', '4822 (node worker) Z 1 4821 4821'],
|
||||
['/proc/7001/stat', '7001 (other) S 1 7001 7001'],
|
||||
]);
|
||||
const readLinuxGroup = () =>
|
||||
readLinuxProcessGroupRunning(4821, {
|
||||
readdirImpl: () => [
|
||||
{ name: '4822', isDirectory: () => true },
|
||||
{ name: '7001', isDirectory: () => true },
|
||||
],
|
||||
readFileImpl: (path) => stats.get(path),
|
||||
});
|
||||
|
||||
expect(readLinuxGroup()).toBe(false);
|
||||
expect(
|
||||
isProcessGroupRunning(4821, {
|
||||
platform: 'linux',
|
||||
killImpl: vi.fn(),
|
||||
readLinuxProcessGroup: readLinuxGroup,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProcessGroupRunning(4821, {
|
||||
platform: 'linux',
|
||||
killImpl: vi.fn(),
|
||||
readLinuxProcessGroup: () => null,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
stats.set('/proc/4822/stat', '4822 (node worker) R 1 4821 4821');
|
||||
expect(readLinuxGroup()).toBe(true);
|
||||
});
|
||||
|
||||
test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
|
||||
@@ -5,7 +5,11 @@ import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { spawnChild, terminateChildTree } from '../scripts/start-dev-stack.mjs';
|
||||
import {
|
||||
isProcessGroupRunning,
|
||||
spawnChild,
|
||||
terminateChildTree,
|
||||
} from '../scripts/start-dev-stack.mjs';
|
||||
import {
|
||||
buildTauriArguments,
|
||||
runTauriDev,
|
||||
@@ -200,7 +204,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(() => process.kill(-cliChild.pid, 0)).toThrow();
|
||||
expect(isProcessGroupRunning(cliChild.pid)).toBe(false);
|
||||
} finally {
|
||||
if (Number.isInteger(cliChild?.pid)) {
|
||||
try {
|
||||
|
||||
@@ -58,6 +58,17 @@ function position(
|
||||
};
|
||||
}
|
||||
|
||||
function automaticPosition(
|
||||
resourceId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): ProjectResourceCanvasPosition {
|
||||
return {
|
||||
...position(resourceId, x, y),
|
||||
manuallyPlaced: false,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.__TAURI__ = undefined;
|
||||
@@ -72,6 +83,117 @@ describe('useProjectResourceCanvasLayout', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for dependency graph initialization before reading or writing layout', async () => {
|
||||
const updates: ProjectResourceCanvasPosition[][] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return persistedLayout('dependency', 0, []);
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
const positions = structuredClone(
|
||||
args?.positions as ProjectResourceCanvasPosition[],
|
||||
);
|
||||
updates.push(positions);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: persistedLayout('dependency', 1, positions),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const shallowResource = resource('resource-a');
|
||||
const deepResource = { ...shallowResource, dependencyDepth: 2 };
|
||||
const { result, rerender } = renderHook(
|
||||
({ initializationReady, resources }) =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
resources,
|
||||
initializationReady,
|
||||
rederiveAutomaticPositions: true,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
initializationReady: false,
|
||||
resources: [shallowResource],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
expect(result.current.layout.positions).toEqual([]);
|
||||
|
||||
rerender({ initializationReady: true, resources: [deepResource] });
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(result.current.layout.positions[0]).toMatchObject({
|
||||
resourceId: 'resource-a',
|
||||
x: 392,
|
||||
manuallyPlaced: false,
|
||||
});
|
||||
expect(invoke.mock.calls[0]?.[0]).toBe(
|
||||
'read_local_project_resource_canvas_layout',
|
||||
);
|
||||
});
|
||||
|
||||
it('rederives automatic dependency positions while preserving manual positions', async () => {
|
||||
const resourceA = { ...resource('resource-a'), dependencyDepth: 2 };
|
||||
const resourceB = resource('resource-b');
|
||||
const updates: ProjectResourceCanvasPosition[][] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return persistedLayout('dependency', 4, [
|
||||
automaticPosition('resource-a', 0, 0),
|
||||
position('resource-b', 600, 40),
|
||||
]);
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
const positions = structuredClone(
|
||||
args?.positions as ProjectResourceCanvasPosition[],
|
||||
);
|
||||
updates.push(positions);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: persistedLayout('dependency', 5, positions),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
resources: [resourceA, resourceB],
|
||||
rederiveAutomaticPositions: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(result.current.layout.positions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-a',
|
||||
x: 392,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-b',
|
||||
x: 600,
|
||||
y: 40,
|
||||
manuallyPlaced: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
|
||||
const resourceA = resource('resource-a');
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-07-28`
|
||||
更新时间:`2026-08-03`
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
|
||||
现有六个专业组为:
|
||||
|
||||
| group | 普通用户名称 | 当前职责 |
|
||||
| --- | --- | --- |
|
||||
| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 |
|
||||
| `art` | 美术组 | 角色、场景、UI、动画和美术素材 |
|
||||
| `code` | 程序组 | 可运行原型、模块实现和工程验证 |
|
||||
| `balance` | 数值组 | 速度、生命、得分和难度参数 |
|
||||
| `audio` | 音频组 | 背景音乐、音效和音频资源 |
|
||||
| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 |
|
||||
| group | 普通用户名称 | 当前职责 |
|
||||
| ------------ | ------------ | ---------------------------------- |
|
||||
| `design` | 策划组 | 玩法规格、界面原型、规则与验收口径 |
|
||||
| `art` | 美术组 | 角色、场景、UI、动画和美术素材 |
|
||||
| `code` | 程序组 | 可运行原型、模块实现和工程验证 |
|
||||
| `balance` | 数值组 | 速度、生命、得分和难度参数 |
|
||||
| `audio` | 音频组 | 背景音乐、音效和音频资源 |
|
||||
| `publishing` | 发布组 | 质量评审、试玩、打包和发布准备 |
|
||||
|
||||
- 底栏默认突出策划、美术、程序三组。
|
||||
- 允许在同一底栏展开数值、音频、发布组,不删除既有专业组。
|
||||
@@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
|
||||
|
||||
### 5.2 资源画布布局(P1)
|
||||
|
||||
实现状态(2026-07-28):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;关系线、资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
|
||||
实现状态(2026-08-03):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
|
||||
|
||||
```ts
|
||||
type ProjectResourceCanvasLayout = {
|
||||
@@ -237,16 +237,30 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。
|
||||
- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。
|
||||
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。
|
||||
- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。
|
||||
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
|
||||
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
|
||||
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
|
||||
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
|
||||
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
|
||||
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
|
||||
- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。
|
||||
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
|
||||
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。
|
||||
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
|
||||
|
||||
#### 5.2.5 资源依赖关系图层
|
||||
|
||||
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。实时拖动坐标属于 DOM 临时状态,不得逐帧通过 Tauri IPC 交给 Rust。
|
||||
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
|
||||
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
|
||||
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
|
||||
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
|
||||
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
|
||||
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
|
||||
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
|
||||
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。
|
||||
- Pointer Move 必须按动画帧合并并完全避开工作台父组件 state:拖动卡片通过 ref 直接更新 CSS 坐标,SVG 通过命令式句柄只更新当前资源局部索引关联的 path。基础 positions 不随每帧复制,静态卡片和 SVG 拓扑保持复用,非拖动卡片不得因预览帧重新渲染。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
```ts
|
||||
@@ -382,9 +396,20 @@ type ProjectAgentMudPointAttribution = {
|
||||
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
|
||||
7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。
|
||||
|
||||
### 7.3 P1 资源依赖关系图验收
|
||||
|
||||
1. dependency 模式同时正确显示橙色实线资源引用与灰色虚线任务流;type 模式没有图层或连线。
|
||||
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
|
||||
3. 多资源任务依赖只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线。
|
||||
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
|
||||
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。
|
||||
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
|
||||
7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。
|
||||
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 资源画布布局持久化切片不实现资源关系线、资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因。
|
||||
- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
|
||||
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
|
||||
- 不修改 SpacetimeDB schema。
|
||||
- 不开放普通用户 Agent.md/Skill。
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state
|
||||
|
||||
- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。
|
||||
- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depth;dependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。
|
||||
- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDB;type 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。
|
||||
- 验证:延迟图 Promise 证明终态前零布局读取/写入,手动位置保持且自动位置按最终深度协调;4096 张真实卡片连续拖动证明非拖动卡片零重渲染、静态 SVG 不重建、Observer 单实例,并以 Chromium p95 `<16.7ms` 和零 `>50ms` long task 验收。
|
||||
|
||||
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
|
||||
|
||||
## 记录格式
|
||||
@@ -16,6 +23,19 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG
|
||||
|
||||
- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。
|
||||
- 决策:dependency 模式由 Tauri Rust 只读命令从当前 manifest、资源卡身份和有界 `.agent/agent.db` 审计构建稳定 `ProjectResourceGraph` read model,前端只归一化 DTO、测量卡片坐标并用原生 SVG 渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。Rust 以迭代式强连通分量分析识别资源环和完整任务 DAG 环,并返回资源局部连接索引。
|
||||
- 任务身份:External Editor 响应中的 `source.taskId` 是平台生成任务 ID,不等于本地 manifest task ID,禁止据此分配 producer。画布资产只接受 `agent.runtime.canvas.asset_generate` 审计中经当前 manifest task 校验的 `assetId -> agentId`;证据缺失、冲突或已超出有界读取窗口时不生成对应 task flow。任务产物与 Agent 回执继续使用自身已有的 manifest task 身份。
|
||||
- 生命周期与边界:Pointer Move 先用 `requestAnimationFrame` 合帧;基础 positions 与拖动预览分离,SVG 静态拓扑保持复用,每帧只按局部索引更新拖动资源关联的 reference edge 和 task flow。`ResizeObserver` 在单个图层生命周期只创建一次。type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、Observer 和窗口监听。SVG 统一 `pointer-events: none`;path、marker、图结构和 section 原点从不持久化。
|
||||
- 数据边界:本切片只新增 Tauri Rust 只读 read model,不修改 layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server、SpacetimeDB schema 或生成绑定,也不引入第三方图表库。dependency section 只在显示层额外预留 `64px` 右侧视觉 gutter,卡片坐标和持久化布局不变。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 的 Tauri project read model/command、项目开发资源投影、依赖图 DTO、SVG overlay、样式与前后端测试,以及工作台 PRD 和客户端实施计划。
|
||||
- 验证方式:Rust 定向测试覆盖真实 producer 映射、拒绝复用外部 `taskId`、证据缺失、去重、无效 ID、完整任务环、4096 任务链与聚合复杂度;前端模型和 SVG 测试覆盖 DTO 防御过滤、局部上下游、可见资源自引用闭环、搜索、高亮、单帧局部 path 更新和稳定 Observer;AppSurface 覆盖生产数据形状、两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-30 抠图实际后端作为 generationInputs 顶层内部元数据保存
|
||||
|
||||
- 背景:角色、图标图集和 UI 图集抠图派生资产需要保留最终实际执行的处理后端,供后台诊断 BgFilter、阿里云通用抠图和本地键色的降级结果;把抠图模型写成 `generationInputs.fields` 的“处理模型”会进入图片信息,与用户可见输入快照语义冲突,而覆盖正式资产 `model` 又会丢失源生图模型。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user