进项目时按内存 registry 核对预览活体,陈旧记录不再冒充可运行预览

- 新增 features/app-shell/sessionPreview.ts 的 resolveSessionPreviewOnProjectOpen:进入项目时问一次 get_local_game_preview_status(内存 registry 是活体的唯一真相),确实 running 才把它作为本次会话的预览;记录写着 running 而 registry 没有活体时判为陈旧,复用现役 stop_local_game_preview 把落盘记录对齐成 stopped,并同时把会话 manifest 按真相投影成 stopped(否则运行入口会继续拿着打不开的 URL 渲染运行画面)。
- useHomeProjectCreation.ts:enterProjectDevelopment 改为 async,先做这次核对再进工作台,并按核对结果决定会话预览与 manifest 投影;三个调用点(新建项目、创建项目、打开项目)都改为 await。
- preview.rs:同一个项目重启预览时,旧 registry 身份的收尾不再覆盖刚落盘的 running——只有当旧预览属于**另一个项目**时才替它收尾。此前同项目重启会把刚写进去的 running 覆盖成 stopped,下次进项目便丢掉"预览在跑"这条事实(记录反方向失真)。
- preview.rs:stop_local_game_preview_for_root 在没有活体可停、但落盘记录仍是 running 时也把记录收尾成 stopped(stop 的语义变成"真的停干净,包括记录"),因此陈旧记录既可由进入项目触发对齐,也可由用户显式停止对齐。
- tests/project.rs:新增三条 Rust 定向用例——同项目重启后记录仍为 running 且端口是新预览的;换项目启动时旧项目记录写 stopped、新项目写 running;registry 无活体而记录写着 running 时 stop 会把记录对齐成 stopped。
- tests/sessionPreview.test.ts(新增):五条用例覆盖活体优先、陈旧记录对齐并投影、registry 不可读按没有在跑处理、记录本来不是 running 时不发多余停止命令、无 invoke 时零请求。变异验证:把活体核对去掉(直接信落盘记录)→「registry 里没有活体时不给会话预览」与「registry 不可读」两条立即红灯(已实测)。
- project-development.suite.ts:把「打开项目不查预览状态」那条断言改为「恰好核对一次预览活体」,并保留邻近的 start_local_game_preview 必须为 0 的断言——被新行为取代的是"从不核验","开项目不偷偷启动预览"这条原意一字未改。
- PRD §4.1:状态机按当前实现改写(进入项目一律落到 resource-overview;run 的自动进入只认内存 registry 的活体确认;落盘 preview 是记录而非活体,陈旧记录不触发切换也不参与渲染),并把退出收尾一并写进该节说明。
This commit is contained in:
2026-09-11 12:27:31 +08:00
parent 1801d2b227
commit 66bb240115
7 changed files with 397 additions and 19 deletions
@@ -758,7 +758,14 @@ pub(crate) fn start_local_game_preview_at_revision(
}
let (preview, previous_preview) = registry.set_running(preview, stop);
if let Some(previous_preview) = previous_preview.as_ref() {
record_replaced_preview_stop(previous_preview);
// 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享
// 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖
// 成 stopped,下次进项目便丢掉"预览在跑"这条事实。只有旧预览属于**另一个项目**
// 时才替它收尾(那才是真的换项目)。
let previous_status = local_preview_status_from_result(previous_preview);
if ensure_preview_belongs_to_project(&previous_status, root).is_err() {
record_replaced_preview_stop(previous_preview);
}
}
if let Err(error) = append_preview_start_trace_step(root, &preview) {
let _ = registry.stop();
@@ -792,11 +799,23 @@ pub(crate) fn stop_local_game_preview_for_root(
registry: &PreviewRegistry,
) -> Result<LocalPreviewStatus, String> {
let (status, stopped) = registry.stop_for_project(root);
if let Some(root) = root.filter(|_| stopped) {
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
let Some(root) = root else {
return Ok(status);
};
// 没有活体预览可停时,落盘记录仍可能是陈旧的 running:预览服务器是进程内线程、
// 非 Linux 端口还是随机临时端口,客户端退出后记录会一直写着 running。这时把记录
// 对齐成 stopped 才算真的"停"——否则下次进项目会照着陈旧记录渲染打不开的运行
// 界面(进入项目时的活体核对是第二道保险,记录本身也该在自己的生命周期里结束)。
let stale_record = !stopped
&& read_manifest_for_project(root)?
.preview
.is_some_and(|preview| preview.status == GameCreationAppPreviewStatus::Running);
if !stopped && !stale_record {
return Ok(status);
}
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
Ok(status)
}
@@ -4735,6 +4735,87 @@ fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() {
fs::remove_dir_all(root).ok();
}
#[test]
fn restarting_preview_for_the_same_project_keeps_the_running_record() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "同项目重启预览记录测试")
.expect("project init");
let registry = PreviewRegistry::default();
let first = start_local_game_preview_at(&root, &registry).expect("first preview start");
let second = start_local_game_preview_at(&root, &registry).expect("second preview start");
assert_ne!(first.url, second.url, "重启必须换一个监听端口");
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(
manifest["preview"]["status"], "running",
"同一个项目重启预览不能让旧预览的收尾把刚写进去的 running 覆盖成 stopped"
);
assert_eq!(manifest["preview"]["port"], second.port);
let _ = registry.stop();
fs::remove_dir_all(root).ok();
}
#[test]
fn replacing_preview_for_another_project_closes_the_previous_project_record() {
let first_root = unique_project_path();
let second_root = unique_project_path();
init_existing_html_project_at(&first_root, "project-1", "旧项目预览记录测试")
.expect("first project init");
init_existing_html_project_at(&second_root, "project-2", "新项目预览记录测试")
.expect("second project init");
let registry = PreviewRegistry::default();
let _first = start_local_game_preview_at(&first_root, &registry).expect("first preview");
let second = start_local_game_preview_at(&second_root, &registry).expect("second preview");
let previous_manifest: Value =
serde_json::from_str(&fs::read_to_string(first_root.join(".agent/manifest.json")).unwrap())
.expect("previous manifest json");
assert_eq!(
previous_manifest["preview"]["status"], "stopped",
"换项目时旧项目的落盘记录必须收尾"
);
let next_manifest: Value = serde_json::from_str(
&fs::read_to_string(second_root.join(".agent/manifest.json")).unwrap(),
)
.expect("next manifest json");
assert_eq!(next_manifest["preview"]["status"], "running");
assert_eq!(next_manifest["preview"]["port"], second.port);
let _ = registry.stop();
fs::remove_dir_all(first_root).ok();
fs::remove_dir_all(second_root).ok();
}
#[test]
fn stopping_without_a_live_preview_still_reconciles_a_stale_running_record() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-1", "陈旧记录对齐测试").expect("project init");
// 模拟"上次留下的 running 记录":直接写记录,registry 里没有任何活体预览。
record_preview_state(
&root,
GameCreationAppPreviewStatus::Running,
Some("http://127.0.0.1:3210/".to_string()),
Some(3210),
)
.expect("stale record");
let registry = PreviewRegistry::default();
let status = stop_local_game_preview_for_root(Some(&root), &registry).expect("reconcile");
assert_eq!(status, stopped_preview_status());
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["preview"]["status"], "stopped");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_preview_serves_generated_playable_game() {
let root = unique_project_path();
@@ -0,0 +1,102 @@
import type { GameCreationAppPreviewState } from '../../../../../packages/shared/src/contracts/gameCreationApp';
/**
* 进入项目时的预览活体核验。
*
* 为什么不能直接信项目 manifest 里的 `preview`:那是"上次留下的一条记录",不是"现在
* 有没有在跑"。本地预览服务器是**进程内线程**`src-tauri/src/preview.rs` 的
* `thread::spawn` + loopback listener),非 Linux 平台端口还是**随机临时端口**
* `bind_loopback_listener_with_linux_fallback` 里的 `TcpListener::bind(..., 0)`),
* 客户端一退 URL 就永久失效;而落盘记录会一直写着 `running`。照它渲染,用户就会被直接
* 丢进一个打不开的运行界面——这正是"有时候进项目看到的是运行界面"的成因。
*
* 所以进项目时问一次内存 registry`get_local_game_preview_status`):
* - 确实在跑 → 作为**本次会话的预览**交给工作台(工作台据此自动进运行视图);
* - 没在跑但记录写着 running → 记录陈旧:把落盘记录对齐成 stopped(复用现役
* `stop_local_game_preview`,它现在会把陈旧记录一并收尾),本次会话不带预览。
*/
export type SessionPreviewResolution = {
/** 本次会话可用的预览:只有内存 registry 里真的在跑才有值。 */
sessionPreview: GameCreationAppPreviewState | null;
/**
* 会话 manifest 里该投影成的预览状态;`null` 表示落盘记录与真相一致,不用改。
*
* 判定陈旧的同一刻就按真相投影(而不是等落盘写成功):否则一份写不进去的陈旧记录
* (例如项目策略把 `preview.stop` 设成需确认)会让运行入口继续拿着一个打不开的 URL
* 渲染运行画面。落盘那份由这次写入、退出收尾或下次进入时的核对负责收敛。
*/
manifestPreviewPatch: GameCreationAppPreviewState | null;
};
/** 预览命令的最小 invoke 形状(与 App 里 `window.__TAURI__.core.invoke` 一致)。 */
export type PreviewCommandInvoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
type LocalPreviewStatusLike = {
status?: string | null;
url?: string | null;
port?: number | null;
};
function livePreviewFromStatus(
status: LocalPreviewStatusLike | null,
): GameCreationAppPreviewState | null {
const url = typeof status?.url === 'string' ? status.url.trim() : '';
if (status?.status !== 'running' || !url) {
return null;
}
return {
status: 'running',
url,
port: typeof status.port === 'number' ? status.port : null,
};
}
export async function resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview,
}: {
invoke: PreviewCommandInvoke | null | undefined;
projectPath: string;
recordedPreview: GameCreationAppPreviewState | null;
}): Promise<SessionPreviewResolution> {
const trimmedProjectPath = projectPath.trim();
if (!invoke || !trimmedProjectPath) {
return { sessionPreview: null, manifestPreviewPatch: null };
}
let status: LocalPreviewStatusLike | null = null;
try {
status = await invoke<LocalPreviewStatusLike>(
'get_local_game_preview_status',
{ projectPath: trimmedProjectPath },
);
} catch {
// 读不到 registry 就按"没有在跑"处理:宁可让用户点一下「运行」,也不要进一个
// 打不开的运行界面。
status = null;
}
const sessionPreview = livePreviewFromStatus(status);
if (sessionPreview) {
return { sessionPreview, manifestPreviewPatch: null };
}
if (recordedPreview?.status !== 'running') {
return { sessionPreview: null, manifestPreviewPatch: null };
}
try {
await invoke('stop_local_game_preview', {
projectPath: trimmedProjectPath,
});
} catch {
// 对齐落盘记录失败不改判:本次会话照样不把陈旧记录当活体用。
}
return {
sessionPreview: null,
manifestPreviewPatch: { status: 'stopped' },
};
}
@@ -42,6 +42,7 @@ import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
type UseHomeProjectCreationOptions = {
setStatus: Dispatch<SetStateAction<string>>;
@@ -175,15 +176,30 @@ export function useHomeProjectCreation({
}
}
function enterProjectDevelopment(context: LauncherProjectContext) {
setCurrentProjectContext(context);
async function enterProjectDevelopment(context: LauncherProjectContext) {
/**
* 进项目时**不**把 manifest 里的 `preview` 当成本次会话的预览:那是"上次留下的一条
* 落盘记录",预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后
* 记录仍会写着 running。会话预览只由活体确认(下一步的 registry 核对)或本次会话里
* 的 preview.start / preview.status 写入,见 `activeProjectPreview` 的其它赋值点
* 会话预览只认"内存 registry 里真的还在跑"的那一个(见
* `resolveSessionPreviewOnProjectOpen`)。项目 manifest 里的 `preview` 是**落盘记录**、
* 不是活体:预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录
* 仍会写着 running,照它自动进运行视图就是给用户一个打不开的运行界面
*/
setActiveProjectPreview(null);
const session = await resolveSessionPreviewOnProjectOpen({
invoke: resolveTauriInvoke(),
projectPath: context.projectPath,
recordedPreview: context.manifest.preview ?? null,
});
setCurrentProjectContext(
session.manifestPreviewPatch
? {
...context,
manifest: {
...context.manifest,
preview: session.manifestPreviewPatch,
},
}
: context,
);
setActiveProjectPreview(session.sessionPreview);
setAgentRuntimeSummaries([]);
setAgentResults([]);
setProjectPath(context.projectPath);
@@ -263,7 +279,7 @@ export function useHomeProjectCreation({
result.projectPath,
attachments,
);
enterProjectDevelopment({
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
@@ -399,7 +415,7 @@ export function useHomeProjectCreation({
},
);
setStatus('已创建项目');
enterProjectDevelopment({
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
@@ -484,7 +500,7 @@ export function useHomeProjectCreation({
return;
}
setStatus('已打开项目');
enterProjectDevelopment({
await enterProjectDevelopment({
projectPath: trimmedProjectPath,
projectName:
directoryStatus.projectName ||
@@ -6993,11 +6993,13 @@ export function registerProjectSupervisorSurfaceTests() {
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(2);
// 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。
// 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
),
).toHaveLength(0);
).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp';
import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview';
const projectPath = '/tmp/session-preview-project';
function createInvoke(
responses: Record<string, unknown>,
calls: Array<[string, Record<string, unknown> | undefined]> = [],
) {
return vi.fn(async (command: string, args?: Record<string, unknown>) => {
calls.push([command, args]);
if (!(command in responses)) {
throw new Error(`unexpected invoke ${command}`);
}
return responses[command];
});
}
describe('进入项目时的预览活体核验', () => {
it('registry 里没有活体时不给会话预览,并把陈旧的 running 记录对齐成 stopped', async () => {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const invoke = createInvoke(
{
get_local_game_preview_status: {
status: 'stopped',
url: null,
port: null,
root: null,
},
stop_local_game_preview: {
status: 'stopped',
url: null,
port: null,
root: null,
},
},
calls,
);
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
});
// 没有活体预览:本次会话不带预览(工作台因此不会自动进运行视图)。
expect(resolution.sessionPreview).toBeNull();
// 落盘记录被判陈旧,会话 manifest 立刻按真相投影。
expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
expect(calls).toEqual([
['get_local_game_preview_status', { projectPath }],
['stop_local_game_preview', { projectPath }],
]);
});
it('registry 确实在跑时只认活体,且不写落盘记录', async () => {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const invoke = createInvoke(
{
get_local_game_preview_status: {
status: 'running',
url: 'http://127.0.0.1:5210/',
port: 5210,
root: projectPath,
},
},
calls,
);
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
// 落盘记录落后于真相(例如同项目重启后记录没跟上)也不影响:活体说了算。
recordedPreview: { status: 'stopped' },
});
expect(resolution.sessionPreview).toEqual({
status: 'running',
url: 'http://127.0.0.1:5210/',
port: 5210,
});
expect(resolution.manifestPreviewPatch).toBeNull();
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
});
it('registry 不可读时按没有在跑处理,不把落盘记录当活体', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'get_local_game_preview_status') {
throw new Error('需要在陶泥儿客户端内运行');
}
return { status: 'stopped', url: null, port: null, root: null };
});
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
});
expect(resolution.sessionPreview).toBeNull();
expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' });
});
it('记录本来就不是 running 时只读活体,不发多余的停止命令', async () => {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const invoke = createInvoke(
{
get_local_game_preview_status: {
status: 'stopped',
url: null,
port: null,
root: null,
},
},
calls,
);
const stoppedRecord: GameCreationAppPreviewState = { status: 'stopped' };
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke,
projectPath,
recordedPreview: stoppedRecord,
});
expect(resolution).toEqual({
sessionPreview: null,
manifestPreviewPatch: null,
});
expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]);
});
it('没有 invoke(浏览器里跑)时不做任何请求', async () => {
const resolution = await resolveSessionPreviewOnProjectOpen({
invoke: null,
projectPath,
recordedPreview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
});
expect(resolution).toEqual({
sessionPreview: null,
manifestPreviewPatch: null,
});
});
});
@@ -143,11 +143,11 @@
### 4.1 主视窗
```text
resource-overview
resource-overview(进入项目的初始态)
-> resource-editor.derive(静态图片、SVG、视频、音频、文档/代码、Agent 回执“编辑资源”)
-> resource-editor.version-branch(项目版本“编辑资源”)
-> ui-editorUI 原型与 UI 资源“编辑资源”)
-> run存在 runnableVersion 且 loopback preview 可启动
-> run用户点运行入口/播放,或进入项目时内存 registry 确认预览仍在运行
resource-editor.* | ui-editor
-> resource-overview(取消或保存投影完成)
@@ -162,7 +162,7 @@ run.paused
-> resource-overview
```
运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。素材创作的完整 opening/editing/generating/saving/cancelling/failed/recovering 状态、草稿身份和迟到结果门禁以 [`【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`](../technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) 为准。
进入项目时一律先落到 `resource-overview``run` 的自动进入只认内存 registry 的活体确认:只有本地预览服务器确实还在跑(`get_local_game_preview_status` 返回 `running`)才直接切过去。项目 manifest 里的 `preview` 是**落盘记录**而不是活体(预览服务器是进程内线程,非 Linux 平台端口还是随机临时端口),客户端退出后它会停留在 `running`;陈旧记录既不触发自动切换,也不允许被当成可运行预览渲染——进入项目时核对到记录陈旧就把它对齐成 `stopped`,客户端退出时也会统一停掉预览并收尾记录。运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。素材创作的完整 opening/editing/generating/saving/cancelling/failed/recovering 状态、草稿身份和迟到结果门禁以 [`【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`](../technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) 为准。
### 4.2 测试切片