WIP: 实现资源依赖关系图层 #126

Closed
menghao wants to merge 18 commits from codex/dependency-graph into master
28 changed files with 4277 additions and 441 deletions
@@ -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,
))
@@ -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
@@ -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");
+98 -2
View File
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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 };
}
@@ -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` 必须存在于当前 manifestExternal 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 depthdependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。
- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDBtype 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 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 更新和稳定 ObserverAppSurface 覆盖生产数据形状、两种边、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