876529e668
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx - 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例 - 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls - App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog - 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract) - 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-* - 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量 - 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件 - 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板) - 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条 - 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数 - 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||
|
||
export type ProjectManifestSnapshotSource =
|
||
| 'initial'
|
||
| 'chat'
|
||
| 'asset-command'
|
||
| 'asset-event'
|
||
| 'poll';
|
||
|
||
export type ProjectManifestSnapshot = {
|
||
projectPath: string;
|
||
projectId: string;
|
||
revision: number;
|
||
manifest: GameCreationAppManifest;
|
||
source: ProjectManifestSnapshotSource;
|
||
commitId?: string;
|
||
eventId?: string;
|
||
};
|
||
|
||
export type ProjectManifestSnapshotMetadata = Omit<
|
||
ProjectManifestSnapshot,
|
||
'projectPath' | 'manifest'
|
||
>;
|
||
|
||
export type ProjectManifestMergeState = {
|
||
projectPath: string;
|
||
projectId: string;
|
||
revision: number;
|
||
manifestFingerprint: string;
|
||
casFingerprint: string;
|
||
seenCommitRevisions: string[];
|
||
seenEventIds: string[];
|
||
};
|
||
|
||
export type ProjectManifestMergeDecision =
|
||
| 'accepted'
|
||
| 'duplicate'
|
||
| 'stale-revision'
|
||
| 'scope-mismatch'
|
||
| 'revision-conflict';
|
||
|
||
const MAX_DEDUPE_IDENTITIES = 256;
|
||
|
||
function appendBounded(values: string[], value: string | undefined) {
|
||
if (!value || values.includes(value)) {
|
||
return values;
|
||
}
|
||
return [...values, value].slice(-MAX_DEDUPE_IDENTITIES);
|
||
}
|
||
|
||
function manifestFingerprint(manifest: GameCreationAppManifest) {
|
||
return JSON.stringify(manifest);
|
||
}
|
||
|
||
/**
|
||
* CAS 的判据面:**只认资源与版本**。
|
||
*
|
||
* 权威契约在 `project/manifest.rs::append_agent_game_iteration_version_at`:revision 是跨
|
||
* 界面排序边界,它保护的对象是「这一版项目用了哪些资源、有哪些正式版本」。清单里还有一类
|
||
* **非 CAS 账目状态**——`preview` 的运行地址与端口、任务进度、项目名。它们由
|
||
* `record_preview_state` / `record_command_run` / `rename_local_game_project` 这类簿记写入
|
||
* 直接落盘而**刻意不推 revision**(预览起停不是源码变更,推 revision 会让运行时的验证凭证
|
||
* 无故漂移)。
|
||
*
|
||
* 所以判据面必须与契约一致:拿整份清单 JSON 当判据,会把每一次预览起停都判成「同一版本号上
|
||
* 的清单内容不一致」,用户看到「资源清单更新被拒收」,而真正要紧的新资源与新版本反倒被这条
|
||
* 假冲突挡在画布之外。
|
||
*/
|
||
function manifestCasFingerprint(manifest: GameCreationAppManifest) {
|
||
return JSON.stringify({
|
||
assets: manifest.assets ?? [],
|
||
versions: manifest.versions ?? [],
|
||
});
|
||
}
|
||
|
||
function commitRevisionIdentity(snapshot: ProjectManifestSnapshot) {
|
||
return snapshot.commitId
|
||
? `${snapshot.projectId}:${snapshot.commitId}:${snapshot.revision}`
|
||
: undefined;
|
||
}
|
||
|
||
function withDedupeIdentities(
|
||
state: ProjectManifestMergeState,
|
||
snapshot: ProjectManifestSnapshot,
|
||
) {
|
||
return {
|
||
...state,
|
||
seenCommitRevisions: appendBounded(
|
||
state.seenCommitRevisions,
|
||
commitRevisionIdentity(snapshot),
|
||
),
|
||
seenEventIds: appendBounded(state.seenEventIds, snapshot.eventId),
|
||
};
|
||
}
|
||
|
||
export function createProjectManifestMergeState(
|
||
snapshot: ProjectManifestSnapshot,
|
||
): ProjectManifestMergeState {
|
||
return withDedupeIdentities(
|
||
{
|
||
projectPath: snapshot.projectPath,
|
||
projectId: snapshot.projectId,
|
||
revision: snapshot.revision,
|
||
manifestFingerprint: manifestFingerprint(snapshot.manifest),
|
||
casFingerprint: manifestCasFingerprint(snapshot.manifest),
|
||
seenCommitRevisions: [],
|
||
seenEventIds: [],
|
||
},
|
||
snapshot,
|
||
);
|
||
}
|
||
|
||
export function mergeProjectManifestSnapshot(
|
||
current: ProjectManifestMergeState,
|
||
snapshot: ProjectManifestSnapshot,
|
||
): {
|
||
decision: ProjectManifestMergeDecision;
|
||
state: ProjectManifestMergeState;
|
||
} {
|
||
if (
|
||
snapshot.projectPath !== current.projectPath ||
|
||
snapshot.projectId !== current.projectId ||
|
||
snapshot.manifest.projectId !== snapshot.projectId
|
||
) {
|
||
return { decision: 'scope-mismatch', state: current };
|
||
}
|
||
if (snapshot.revision < current.revision) {
|
||
return { decision: 'stale-revision', state: current };
|
||
}
|
||
|
||
const fingerprint = manifestFingerprint(snapshot.manifest);
|
||
const casFingerprint = manifestCasFingerprint(snapshot.manifest);
|
||
if (snapshot.revision === current.revision) {
|
||
// 同一版本号上「资源或版本」变了:这是真的 CAS 冲突——有写点改了被保护的内容却没推进
|
||
// revision,必须继续拒收并让用户看见,否则客户端会拿着一份无法定位内容的清单渲染。
|
||
if (casFingerprint !== current.casFingerprint) {
|
||
return { decision: 'revision-conflict', state: current };
|
||
}
|
||
if (fingerprint === current.manifestFingerprint) {
|
||
return {
|
||
decision: 'duplicate',
|
||
state: withDedupeIdentities(current, snapshot),
|
||
};
|
||
}
|
||
// 同一版本号、资源与版本一致,只有非 CAS 账目状态(预览地址/端口、任务进度、项目名)
|
||
// 不同:内容必须落地,revision 既不推进也不回退。
|
||
return {
|
||
decision: 'accepted',
|
||
state: withDedupeIdentities(
|
||
{ ...current, manifestFingerprint: fingerprint },
|
||
snapshot,
|
||
),
|
||
};
|
||
}
|
||
|
||
return {
|
||
decision: 'accepted',
|
||
state: withDedupeIdentities(
|
||
{
|
||
...current,
|
||
revision: snapshot.revision,
|
||
manifestFingerprint: fingerprint,
|
||
casFingerprint,
|
||
},
|
||
snapshot,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 会被用户看见的拒收决策。
|
||
*
|
||
* `mergeProjectManifestSnapshot` 的五个决策里只有这两个代表「本条快照被 CAS 挡下、
|
||
* 手上的清单继续用旧的」:`stale-revision` 是收到的快照版本比手上的旧,
|
||
* `revision-conflict` 是同一版本号上内容不一致。两者都会让新提交的资源进不了画布,
|
||
* 所以必须可见且可恢复。
|
||
*
|
||
* `scope-mismatch` **不在其列**:它表示这条快照根本不属于当前项目(上游串了项目),
|
||
* 对用户没有可执行的语义,只记日志、不弹提示。
|
||
*/
|
||
export type ProjectManifestMergeRejectionDecision =
|
||
| 'stale-revision'
|
||
| 'revision-conflict';
|
||
|
||
export function projectManifestMergeRejectionDecision(
|
||
decision: ProjectManifestMergeDecision,
|
||
): ProjectManifestMergeRejectionDecision | null {
|
||
return decision === 'stale-revision' || decision === 'revision-conflict'
|
||
? decision
|
||
: null;
|
||
}
|
||
|
||
/** 拒收之后的恢复阶段,直接决定提示文案与按钮可用性。 */
|
||
export type ProjectManifestMergeRecoveryStage =
|
||
| 'recovering'
|
||
| 'recovered'
|
||
| 'unresolved';
|
||
|
||
export function describeProjectManifestMergeRejection(
|
||
decision: ProjectManifestMergeRejectionDecision,
|
||
stage: ProjectManifestMergeRecoveryStage,
|
||
): string {
|
||
const cause =
|
||
decision === 'revision-conflict'
|
||
? '同一版本号上的清单内容不一致'
|
||
: '收到的是旧版本的清单快照';
|
||
if (stage === 'recovering') {
|
||
return `资源清单更新被拒收(${cause}),正在重新读取磁盘清单…`;
|
||
}
|
||
if (stage === 'recovered') {
|
||
return `资源清单更新被拒收(${cause}),已按磁盘清单重新对齐`;
|
||
}
|
||
// `unresolved` 不等于"读盘失败":重读**可能已经成功**,只是拿到的那一对
|
||
// `(revision, 清单)` 按 CAS 判据不能被采用(版本号比手上的旧,或读到的是撕裂的一对)。
|
||
// 所以这里说"未能按磁盘清单重新对齐" —— 它对所有进入 `unresolved` 的路径都成立,
|
||
// 而"重新读取磁盘清单失败"只对其中一条成立,是一条会误导用户的假陈述。
|
||
return `资源清单更新被拒收(${cause}),未能按磁盘清单重新对齐,请重新打开项目`;
|
||
}
|
||
|
||
export type ProjectManifestRereadInput = {
|
||
projectPath: string;
|
||
projectId: string;
|
||
readRevision: () => Promise<number>;
|
||
readManifest: () => Promise<GameCreationAppManifest>;
|
||
};
|
||
|
||
/**
|
||
* 拒收之后的恢复手段:**重读磁盘真相**,而不是把被拒的快照硬收下(那等于放宽 CAS)。
|
||
*
|
||
* 为什么重读能解:拒收的成因是快照自带的版本号不足以定位内容(典型是资产命令把
|
||
* **写盘之前**读到的 revision 贴在了**写盘之后**读到的清单上),于是"同一版本号、
|
||
* 内容不同"被 CAS 挡下。此时唯一可靠的解法是回到磁盘问一次当前 `(revision, 清单)`,
|
||
* 用它重建 merge 状态。
|
||
*
|
||
* 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对,
|
||
* 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。
|
||
*
|
||
* 返回 `null` **只表示读到了、但不能采用**(revision 非法、projectId 不一致、
|
||
* 两次 revision 对不上)。IO / IPC 失败会照旧抛出:调用方要能把"读失败"与
|
||
* "读到但不可采用"分开呈现(例如上传成功后配对读失败必须报错,而不是静默不同步)。
|
||
*/
|
||
export async function rereadAuthoritativeProjectManifestSnapshot(
|
||
input: ProjectManifestRereadInput,
|
||
): Promise<ProjectManifestSnapshot | null> {
|
||
const revision = await input.readRevision();
|
||
if (!Number.isSafeInteger(revision) || revision < 0) {
|
||
return null;
|
||
}
|
||
const manifest = await input.readManifest();
|
||
if (manifest.projectId !== input.projectId) {
|
||
return null;
|
||
}
|
||
const confirmedRevision = await input.readRevision();
|
||
if (confirmedRevision !== revision) {
|
||
return null;
|
||
}
|
||
return {
|
||
projectPath: input.projectPath,
|
||
projectId: input.projectId,
|
||
revision,
|
||
manifest,
|
||
source: 'poll',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 素材上传之后的清单快照:**上传动作与"配对读"必须由同一个函数拥有**。
|
||
*
|
||
* ⚠️ 不许把「上传**前**读到的 revision」贴在「上传**后**读到的清单」上。上传本身会推进
|
||
* 项目 revision(Rust 侧 `advance_agent_runtime_project_revision_locked`),于是这一对是
|
||
* 撕裂的:`mergeProjectManifestSnapshot` 看到"同一版本号、内容不同" ⇒ 判 `revision-conflict`
|
||
* ⇒ 用户每上传一次素材就先看到一条「资源清单更新被拒收(同一版本号上的清单内容不一致)」。
|
||
*
|
||
* 唯一正确的形状是 [`rereadAuthoritativeProjectManifestSnapshot`]:它**在同一次读里配对**
|
||
* revision 与清单,并再读一次 revision 确认没被写盘插队。把上传与这次配对读放在同一个
|
||
* 函数里,是为了让"先读 revision 再上传"这种错配**不可能**再被写出来 —— 调用方拿不到
|
||
* 中间那个 revision,也就没有机会把它贴错。
|
||
*/
|
||
export async function uploadProjectAssetFilesAndReadSnapshot(input: {
|
||
projectPath: string;
|
||
projectId: string;
|
||
commitId: string;
|
||
files: readonly {
|
||
fileName: string;
|
||
mediaType: string;
|
||
bytes: number[];
|
||
}[];
|
||
invoke<T>(command: string, args: Record<string, unknown>): Promise<T>;
|
||
}): Promise<ProjectManifestSnapshot | null> {
|
||
for (const file of input.files) {
|
||
await input.invoke('upload_local_asset', {
|
||
projectPath: input.projectPath,
|
||
fileName: file.fileName,
|
||
mediaType: file.mediaType,
|
||
bytes: file.bytes,
|
||
});
|
||
}
|
||
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
|
||
projectPath: input.projectPath,
|
||
projectId: input.projectId,
|
||
readRevision: async () => {
|
||
const status = await input.invoke<{ revision: number }>(
|
||
'get_local_game_project_revision',
|
||
{ projectPath: input.projectPath },
|
||
);
|
||
return status.revision;
|
||
},
|
||
readManifest: () =>
|
||
input.invoke<GameCreationAppManifest>('get_local_game_manifest', {
|
||
projectPath: input.projectPath,
|
||
commandId: 'asset.list',
|
||
}),
|
||
});
|
||
return fresh
|
||
? { ...fresh, source: 'asset-command', commitId: input.commitId }
|
||
: null;
|
||
}
|
||
|
||
export type ResourceFocusIntent = {
|
||
flowId: string;
|
||
saveAttemptId: string;
|
||
sessionId: string;
|
||
draftId: string;
|
||
commitId: string;
|
||
projectPath: string;
|
||
projectId: string;
|
||
focusGeneration: number;
|
||
resourceId: string | null;
|
||
completed: boolean;
|
||
};
|
||
|
||
export type ResourceFocusResolution =
|
||
| 'invalid'
|
||
| 'wait-projection'
|
||
| 'wait-layout'
|
||
| 'hidden'
|
||
| 'wait-dom'
|
||
| 'focus'
|
||
| 'completed';
|
||
|
||
export function resolveResourceFocusIntent(
|
||
intent: ResourceFocusIntent,
|
||
input: {
|
||
projectPath: string;
|
||
projectId: string;
|
||
flowId: string | null;
|
||
focusGeneration: number;
|
||
projected: boolean;
|
||
dependencyLayoutSettled: boolean;
|
||
dependencyPositioned: boolean;
|
||
typeLayoutSettled: boolean;
|
||
typePositioned: boolean;
|
||
visible: boolean;
|
||
domRendered: boolean;
|
||
},
|
||
): ResourceFocusResolution {
|
||
if (intent.completed) {
|
||
return 'completed';
|
||
}
|
||
if (
|
||
intent.projectPath !== input.projectPath ||
|
||
intent.projectId !== input.projectId ||
|
||
intent.flowId !== input.flowId ||
|
||
intent.focusGeneration !== input.focusGeneration
|
||
) {
|
||
return 'invalid';
|
||
}
|
||
if (!intent.resourceId || !input.projected) {
|
||
return 'wait-projection';
|
||
}
|
||
if (
|
||
!input.dependencyLayoutSettled ||
|
||
!input.typeLayoutSettled ||
|
||
!input.dependencyPositioned ||
|
||
!input.typePositioned
|
||
) {
|
||
return 'wait-layout';
|
||
}
|
||
if (!input.visible) {
|
||
return 'hidden';
|
||
}
|
||
if (!input.domRendered) {
|
||
return 'wait-dom';
|
||
}
|
||
return 'focus';
|
||
}
|