完成资源依赖排列阶段五
Project CI / Frontend tests (pull_request) Failing after 21s
Project CI / Repository checks (pull_request) Successful in 1m1s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Native shell tests (pull_request) Successful in 11m49s

计算资源引用 SCC 与任务深度下限
拆分 producer assignment 和布局深度合同
补齐依赖布局回归测试
同步工作台 PRD、技术方案与项目记忆
This commit is contained in:
2026-08-03 17:51:59 +08:00
parent d374f3292a
commit ba996aad80
8 changed files with 193 additions and 22 deletions
@@ -50,6 +50,12 @@ pub(crate) struct ProjectResourceConnectionIndex {
pub(crate) struct ProjectResourceProducerAssignment {
pub resource_id: String,
pub task_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectResourceDependencyDepth {
pub resource_id: String,
pub dependency_depth: u32,
}
@@ -61,6 +67,7 @@ pub(crate) struct ProjectResourceGraphReadModel {
pub task_flows: Vec<ProjectResourceTaskFlow>,
pub connection_index: Vec<ProjectResourceConnectionIndex>,
pub producer_assignments: Vec<ProjectResourceProducerAssignment>,
pub dependency_depths: Vec<ProjectResourceDependencyDepth>,
pub unresolved_reference_resource_ids: Vec<String>,
pub cyclic_resource_ids: Vec<String>,
pub cyclic_task_ids: Vec<String>,
@@ -194,6 +201,7 @@ fn analyze_directed_cycles<'a>(
fn dependency_depth_by_node(
analysis: &CycleAnalysis,
edges: &[DirectedEdge],
minimum_depth_by_node: &BTreeMap<String, u32>,
) -> BTreeMap<String, u32> {
let component_count = analysis
.component_by_node
@@ -223,6 +231,12 @@ fn dependency_depth_by_node(
.filter_map(|(component, degree)| (*degree == 0).then_some(component))
.collect::<BTreeSet<_>>();
let mut depth_by_component = vec![0u32; component_count];
for (node_id, minimum_depth) in minimum_depth_by_node {
let Some(component) = analysis.component_by_node.get(node_id) else {
continue;
};
depth_by_component[*component] = depth_by_component[*component].max(*minimum_depth);
}
while let Some(component) = ready.pop_first() {
for &target in &outgoing[component] {
depth_by_component[target] =
@@ -464,7 +478,22 @@ pub(crate) fn build_project_resource_graph(
})
.collect::<Vec<_>>();
let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges);
let task_dependency_depths = dependency_depth_by_node(&task_cycles, &task_dependency_edges);
let task_dependency_depths =
dependency_depth_by_node(&task_cycles, &task_dependency_edges, &BTreeMap::new());
let minimum_resource_dependency_depths = producer_by_resource_id
.iter()
.filter_map(|(resource_id, task_id)| {
task_dependency_depths
.get(task_id)
.copied()
.map(|depth| (resource_id.clone(), depth))
})
.collect::<BTreeMap<_, _>>();
let resource_dependency_depths = dependency_depth_by_node(
&reference_cycles,
&reference_directed_edges,
&minimum_resource_dependency_depths,
);
let task_flows = task_dependency_edges
.iter()
.filter_map(|edge| {
@@ -538,13 +567,18 @@ pub(crate) fn build_project_resource_graph(
.into_iter()
.map(|(resource_id, task_id)| ProjectResourceProducerAssignment {
resource_id,
dependency_depth: task_dependency_depths
.get(&task_id)
.copied()
.unwrap_or_default(),
task_id,
})
.collect(),
dependency_depths: resource_dependency_depths
.into_iter()
.map(
|(resource_id, dependency_depth)| ProjectResourceDependencyDepth {
resource_id,
dependency_depth,
},
)
.collect(),
unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(),
cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(),
cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(),
@@ -680,7 +714,18 @@ mod tests {
graph
.producer_assignments
.iter()
.map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth,))
.map(|assignment| (assignment.resource_id.as_str(), assignment.task_id.as_str()))
.collect::<BTreeMap<_, _>>(),
BTreeMap::from([
("asset:spec", "art-director"),
("asset:ui", "design-foundation"),
]),
);
assert_eq!(
graph
.dependency_depths
.iter()
.map(|depth| (depth.resource_id.as_str(), depth.dependency_depth))
.collect::<BTreeMap<_, _>>(),
BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]),
);
@@ -720,6 +765,14 @@ mod tests {
assert_eq!(graph.reference_edges.len(), 1);
assert!(graph.task_flows.is_empty());
assert!(graph.producer_assignments.is_empty());
assert_eq!(
graph
.dependency_depths
.iter()
.map(|depth| (depth.resource_id.as_str(), depth.dependency_depth))
.collect::<BTreeMap<_, _>>(),
BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]),
);
}
#[test]
@@ -810,10 +863,10 @@ mod tests {
.all(|index| index.task_flow_ids.len() <= 2));
assert_eq!(
graph
.producer_assignments
.dependency_depths
.iter()
.find(|assignment| assignment.resource_id == "resource:4095")
.map(|assignment| assignment.dependency_depth),
.find(|depth| depth.resource_id == "resource:4095")
.map(|depth| depth.dependency_depth),
Some(4095),
);
}
@@ -841,13 +894,72 @@ mod tests {
);
let depths = graph
.producer_assignments
.dependency_depths
.iter()
.map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth))
.map(|depth| (depth.resource_id.as_str(), depth.dependency_depth))
.collect::<BTreeMap<_, _>>();
assert_eq!(depths["source-resource"], 0);
assert_eq!(depths["cycle-a-resource"], 1);
assert_eq!(depths["cycle-b-resource"], 1);
assert_eq!(depths["target-resource"], 2);
}
#[test]
fn dependency_depth_uses_reference_sccs_after_task_depth_floors() {
let graph = build_project_resource_graph(
&manifest(
vec![
task("source-task", &[]),
task("late-task", &["source-task"]),
],
vec![
asset("base", Some("external-base"), &[], None),
asset(
"cycle-a",
Some("external-cycle-a"),
&["external-base", "external-cycle-b"],
None,
),
asset(
"cycle-b",
Some("external-cycle-b"),
&["external-cycle-a"],
None,
),
asset(
"target",
Some("external-target"),
&["external-cycle-b"],
None,
),
],
),
vec![
resource("asset:base", Some("base"), None),
resource("asset:cycle-a", Some("cycle-a"), None),
resource("asset:cycle-b", Some("cycle-b"), None),
resource("asset:target", Some("target"), None),
],
&[
serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "base", "agentId": "source-task"}),
serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "cycle-a", "agentId": "late-task"}),
],
false,
);
let depths = graph
.dependency_depths
.iter()
.map(|depth| (depth.resource_id.as_str(), depth.dependency_depth))
.collect::<BTreeMap<_, _>>();
assert_eq!(depths["asset:base"], 0);
assert_eq!(depths["asset:cycle-a"], 1);
assert_eq!(depths["asset:cycle-b"], 1);
assert_eq!(depths["asset:target"], 2);
assert_eq!(
graph.cyclic_resource_ids,
vec!["asset:cycle-a", "asset:cycle-b"]
);
assert_eq!(graph.task_flows.len(), 1);
}
}
@@ -33,6 +33,10 @@ export type ProjectResourceConnectionIndexDto = {
export type ProjectResourceProducerAssignment = {
resourceId: string;
taskId: string;
};
export type ProjectResourceDependencyDepth = {
resourceId: string;
dependencyDepth: number;
};
@@ -42,6 +46,7 @@ export type ProjectResourceGraphReadModel = {
taskFlows: ProjectResourceTaskFlow[];
connectionIndex: ProjectResourceConnectionIndexDto[];
producerAssignments: ProjectResourceProducerAssignment[];
dependencyDepths: ProjectResourceDependencyDepth[];
unresolvedReferenceResourceIds: string[];
cyclicResourceIds: string[];
cyclicTaskIds: string[];
@@ -185,13 +190,19 @@ export function normalizeProjectResourceGraph(
continue;
}
producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId);
}
for (const depth of readModel.dependencyDepths) {
if (
Number.isSafeInteger(assignment.dependencyDepth) &&
assignment.dependencyDepth >= 0
resourceIds.has(depth.resourceId) &&
Number.isSafeInteger(depth.dependencyDepth) &&
depth.dependencyDepth >= 0
) {
dependencyDepthByResourceId.set(
assignment.resourceId,
assignment.dependencyDepth,
depth.resourceId,
Math.max(
dependencyDepthByResourceId.get(depth.resourceId) ?? 0,
depth.dependencyDepth,
),
);
}
}
@@ -93,6 +93,7 @@ function graphFixture() {
taskFlowIds: resourceId === 'unrelated' ? [] : [flowId],
})),
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['unrelated'],
cyclicTaskIds: [],
@@ -373,6 +374,7 @@ describe('ResourceDependencyOverlay', () => {
],
})),
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
@@ -78,11 +78,14 @@ function resourceGraphForInputs(args?: Record<string, unknown>) {
{
resourceId: resource.resourceId,
taskId: resource.producerTaskId,
dependencyDepth: 0,
},
]
: [],
),
dependencyDepths: resources.map((resource) => ({
resourceId: resource.resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
@@ -1103,13 +1106,25 @@ export function registerProjectWorkbenchFoundationTests() {
{
resourceId: 'asset:dependency-spec',
taskId: 'art-director',
dependencyDepth: 0,
},
{
resourceId: 'asset:dependency-ui',
taskId: 'design-foundation',
},
],
dependencyDepths: [
{
resourceId: 'asset:dependency-spec',
dependencyDepth: 0,
},
{
resourceId: 'asset:dependency-ui',
dependencyDepth: 1,
},
{
resourceId: 'asset:unrelated-cycle',
dependencyDepth: 0,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['asset:unrelated-cycle'],
@@ -15,6 +15,7 @@ function readModel(
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
@@ -130,23 +131,42 @@ describe('resource dependency graph model', () => {
it('keeps real producer assignments and audit truncation metadata', () => {
const graph = normalizeProjectResourceGraph(
readModel({
resourceIds: ['asset:spec', 'asset:ui'],
resourceIds: ['asset:spec', 'asset:ui', 'asset:derived'],
producerAssignments: [
{
resourceId: 'asset:spec',
taskId: 'art-director',
dependencyDepth: 0,
},
{
resourceId: 'asset:ui',
taskId: 'design-foundation',
dependencyDepth: 1,
},
{
resourceId: 'asset:deleted',
taskId: 'task-1',
},
],
dependencyDepths: [
{
resourceId: 'asset:spec',
dependencyDepth: 0,
},
{
resourceId: 'asset:ui',
dependencyDepth: 1,
},
{
resourceId: 'asset:deleted',
dependencyDepth: 99,
},
{
resourceId: 'asset:ui',
dependencyDepth: 0,
},
{
resourceId: 'asset:derived',
dependencyDepth: 2,
},
],
producerMappingTruncated: true,
}),
@@ -162,6 +182,7 @@ describe('resource dependency graph model', () => {
new Map([
['asset:spec', 0],
['asset:ui', 1],
['asset:derived', 2],
]),
);
expect(graph.producerMappingTruncated).toBe(true);
@@ -260,6 +260,7 @@ type UpdateProjectResourceCanvasLayoutResult =
#### 5.2.6 资源依赖关系图层
- 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
@@ -271,6 +272,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、选择、项目切换或 section origin 变化而更新。
- `ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对聚合,禁止为了计算深度或绘线生成资源笛卡尔积。
### 5.3 资源类型与替换兼容性(P1)
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-03 资源依赖阶段五以引用 SCC 深度驱动自动排列
- 背景:资源关系图已经能展示精确引用与聚合任务流,但 dependency 自动布局只消费可信 producer 对应的任务 DAG 深度;同一任务生成的派生资源、没有 producer 审计的 manifest 资源和资源引用环均无法稳定体现“被引用资源在前、引用资源在后”的顺序。
- 决策:`read_local_project_resource_graph` 把 producer assignment 与布局深度拆成两个只读字段。Rust 先压缩完整任务图并把可信 producer 的任务深度作为资源下限,再对精确资源引用图做迭代式 SCC 压缩和确定性最长层级传播;同一引用环共享深度,环后资源递增一层,没有引用关系的资源保持默认深度 `0`。前端只校验并消费 `dependencyDepths`,沿用现有自动位置协调和 SVG 几何,不自行推导关系。
- 边界:不修改 manifest、layout sidecar、External Editor API、api-server 或 SpacetimeDB;不恢复资源卡拖动。已有 `manuallyPlaced=true` 坐标继续保留,只有可派生自动坐标会按新深度重算;任务流继续按任务对聚合,不展开资源笛卡尔积。
- 验证方式:Rust 定向测试覆盖无 producer 的精确引用、任务深度下限、资源引用环 SCC、环后资源与 4096 任务链;前端 DTO、布局 Hook、纯布局和 SVG 测试覆盖独立深度字段、自动重排与手动坐标保留,并运行 shell typecheck、编码检查和 `git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 资源聚焦阶段四采用只读受控文档与媒体链路
- 背景:阶段一至三已经完成资源卡禁拖、固定四类资源投影与中央聚焦容器,但只有 PNG / JPEG / WEBP 和合法 Agent 文本回执具备真实主体预览;本地文档、SVG / 视频与音频仍只有路径和元数据。飞书需求同时把“编辑并生成新资源”写为条件项,而当前仓库尚未具备从画板返回后的血缘登记与自动选中闭环。
@@ -397,7 +397,7 @@ game-project/
2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar、既有布局模型、api-server 或 SpacetimeDB
- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`
- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、独立 `dependencyDepths`循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`
- 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线。
- task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`External Editor 返回并保存在 `source.taskId``task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份。
- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。
@@ -408,7 +408,7 @@ game-project/
- 基础 positions 保持稳定;卡片 Pointer Move 不进入 SVG preview,只有搜索、选择、项目切换、真实 positions 或 section origin 变化才重新协调图层。SVG 几何从不持久化。
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、稳定 Observer、type 模式卸载与项目切换销毁。局部拖动更新与真实 Chromium 拖动性能目标暂缓。
2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。
2026-08-03 阶段五加固:Rust read model producer assignment 与布局深度分离。完整任务图先经 SCC 压缩形成可信 producer 的任务深度下限,精确资源引用图再经迭代式 SCC 压缩和确定性最长层级传播形成所有可见资源的 `dependencyDepths`;因此同一任务的派生资源、缺少 producer 审计的 manifest 资源和引用环都能稳定满足“被引用资源在前、引用资源在后”,没有引用关系的资源保持深度 `0`前端不递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。任务流仍按任务对聚合,不为布局计算或 SVG 绘制生成资源笛卡尔积。
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。