fix: reopen preserved coding projects when selecting their folder

This commit is contained in:
2026-09-21 15:39:57 +08:00
parent 576edb2720
commit 6aa30a57b4
5 changed files with 186 additions and 1 deletions

View File

@@ -0,0 +1,48 @@
# Task: Fix reopening a removed coding project folder
## Identity
- Task ID: 20260921-readd-project-8f4a2c
- Mode: Feature
- Branch: codex/20260921-readd-project-8f4a2c-readd-project
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260921-readd-project-8f4a2c
- Base commit: 576edb2720c00c317b931ea8d33076efae1a0925
- Owner: codex
- Status: Ready for Integration
## Scope
- Diagnose and fix the reported create/use/remove/recreate sequence when the user directly selects the original Code project folder.
- Own the bounded Main project-service correction, project identity unit/Electron regressions, README behavior description, and this record.
## Intent And Constraints
- Concurrent Task Gate Passed: official check/start/status succeeded with the exact identity, feature mode, branch, worktree and main base above. The primary worktree belongs to `20260921-merge-disabled-model-2209f56a`; it remains untouched, including its existing untracked records.
- Planning Gate Passed after loading the entry/active task/memory index, positioning, integrated state, decision index and ADR-008, system architecture, domain/success criteria and relevant evidence/reflection/commitment context. Positioning remains a template; current code, README and accepted ADRs supply the actual product facts.
- Read the 150 peer task records' scope/intent/promotion sections. Historical empty records remain unknown coordination state. Completed project-scaffold and conversation-management work supplies compatible context; no concrete conflicting decision or dependency blocks this isolated repair. No subagents used.
- Keep project identity, type, Agents, Conversations and knowledge project-owned. Do not erase `.makelore`, migrate old namespaces, create a new persistent authority, change Host request/response shapes, change Pi, touch user projects, merge main, install or publish.
- Use diagnosing-bugs with a deterministic real Main-service regression. The exact existing-config guard throws after the catalog has been emptied; this direct evidence makes additional speculative hypothesis ranking and temporary instrumentation unnecessary.
## Outcome
- Root cause: removeProject deletes only the application catalog entry, correctly leaving project files intact. createProject previously always called createLocalCodingProject, whose metadata creation rejects an existing `.makelore/project.json`; the route maps that rejection to the screenshot's `该编程项目已经存在。`.
- Direct-directory creation with the ordinary automatic-identity choice now reads existing metadata inside the current serialized activation transition. Valid projects are registered/opened and projected through getConfig, retaining identity/type/Agents/Conversations/knowledge and the existing missing-identity backfill. Invalid configuration is reported without overwriting or registering it.
- New child-directory name collisions and explicit bind operations preserve their existing creation conflict behavior. Selecting an already registered folder reuses its catalog entry. No new dependency, wrapper, migration or UI control is needed.
- README now explains removal and reattachment. UI before: the original folder leaves the creation dialog showing the duplicate-project error. UI after: the same confirmation reopens the preserved project and enters chat.
## Verification
- Red: `pnpm exec vitest run tests/unit/coding-project-identity.test.ts -t 'reopens a removed project'` failed with `CodingProjectServiceError: Coding project already exists` at the production createProject seam (12 ms test time). The unchanged command passed after the fix.
- Green: 43 tests across coding-project-identity, coding-projects-schema-v2, coding-projects-facade and coding-workspace-store. Regressions cover byte-preserved config/conversation files, Agent/type/identity preservation, knowledge contents, repeated selection, invalid existing metadata, child-directory and explicit-bind conflicts, and legacy identity backfill.
- `pnpm run typecheck` passed. Changed-file ESLint passed for project-service and the two changed test files. `pnpm run build:vite` passed all production targets.
- Additional strict Main-service compilation reports one pre-existing TS2322 at project-service.ts:215 (`config.projectId` is `string | undefined` where `string` is required). Compiling the exact base-commit source through the same command reproduces the identical diagnostic; no new diagnostic was introduced. The temporary baseline source was removed. This existing type contract is outside the reattachment correction.
- `pnpm exec playwright test tests/e2e/coding-project-identity.spec.ts`: 2/2 passed, including real UI creation, removal from Project Configuration, selecting the same directory and returning to chat with unchanged configuration/knowledge. Uses isolated temporary user data and project files, no paid provider call.
- Toolchain: repository-pinned pnpm 10.33.4; frozen-lockfile installation succeeded without lockfile changes. The full unrelated suite and installed-package/native macOS/Linux verification were not run.
## Follow-ups
- Integrate this branch and rebuild the client before the user's installed version benefits from the correction. No merge, installer, installation, push or deployment was performed by this task.
## Promotion Candidates
- Target: `.project-docs/40-domain/business-rules.md` and current-state at Integration Gate. Proposal: document that ordinary direct-folder project creation reconnects valid existing metadata; removing a project forgets its catalog registration and preserves its disk state. Evidence: reproduced failure, bounded Main change, 43 unit regressions and 2 real Electron UI tests. Future impact: prevent reintroducing destructive cleanup or treating a preserved project as an unopenable duplicate. Semantic conflicts: none with ADR-008 identity/configuration ownership. Human confirmation: no additional product-direction decision needed for this bounded defect correction; canonical writes remain Integration-only.

View File

@@ -20,7 +20,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录Main 自动生成内部项目 ID并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json``knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill无需伙伴分配它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录Main 自动生成内部项目 ID并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json``knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill无需伙伴分配它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。
## 当前产品状态

View File

@@ -268,6 +268,22 @@ export class CodingProjectService {
}
try {
return await this.transitionActiveProject(async () => {
// Removing a project only forgets the catalog entry. Selecting its folder
// again must reuse the project-owned configuration, not overwrite it.
if (selectedPath && input.identity.kind === 'create') {
const existing = await readCodingProjectConfigV2(projectPath);
if (existing.status === 'invalid') {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
if (existing.status === 'valid') {
const project = await this.store.openFolder(projectPath);
return { project, value: await this.getConfig(project.id) };
}
}
const { project, config } = await createLocalCodingProject({
projectPath,
projectId,

View File

@@ -25,6 +25,39 @@ async function selectProgrammingProjectFolder(
}
test.describe('Coding project identity UX', () => {
test('reopens the same folder after removing its project from Makelore', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-readd-e2e-'));
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await selectProgrammingProjectFolder(app, projectPath);
const createDialog = page.getByRole('dialog', { name: '新建项目' });
await page.getByTestId('sidebar-create-project').click();
await createDialog.getByRole('button', { name: '选择路径' }).click();
await createDialog.getByRole('button', { name: '确认创建' }).click();
await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible();
const originalConfig = await readProjectConfig(projectPath);
await writeFile(path.join(projectPath, 'knowledge', 'notes.md'), 'Keep my notes');
await page.getByRole('button', { name: '创建项目智能体' }).click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await page.getByRole('button', { name: '删除项目', exact: true }).click();
await page.getByRole('button', { name: '确认删除项目', exact: true }).click();
await expect(page.getByTestId('project-configuration-page')).toHaveCount(0);
await page.getByTestId('sidebar-create-project').click();
await createDialog.getByRole('button', { name: '选择路径' }).click();
await createDialog.getByRole('button', { name: '确认创建' }).click();
await expect(createDialog).toBeHidden();
await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible();
expect(await readProjectConfig(projectPath)).toEqual(originalConfig);
expect(await readFile(path.join(projectPath, 'knowledge', 'notes.md'), 'utf8')).toBe('Keep my notes');
} finally {
await closeElectronApp(app);
await rm(projectPath, { recursive: true, force: true });
}
});
test('keeps the service identity automatic and invisible to young users', async ({ launchElectronApp }) => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-identity-e2e-'));
const app = await launchElectronApp({ skipSetup: true });

View File

@@ -6,7 +6,9 @@ import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { HostApiContext } from '../../electron/api/context';
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import {
createCodingProjectAgent,
createCodingProjectConfigV2,
isCanonicalCodingProjectId,
normalizeCodingProjectConfigV2,
@@ -45,6 +47,92 @@ function makeStore(localId = 'local-project-id') {
}
describe('coding project durable identity', () => {
it('reopens a removed project when its folder is selected for creation again', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const projects = new CodingProjectService(store, {
createProjectId: vi.fn().mockReturnValueOnce(PROJECT_ID).mockReturnValue(NEXT_PROJECT_ID),
});
const input = { projectPath, projectType: 'interactive_ai_app' as const, identity: { kind: 'create' as const } };
const created = await projects.createProject({ ...input, projectType: 'custom' });
const agent = await createCodingProjectAgent(projectPath, {
id: 'builder', avatarId: 'avatar-01', roleName: 'Builder', name: 'My builder',
model: null, modelResolution: 'required', prompt: 'Keep my instructions', skillIds: [],
responsibility: { mission: 'Build my app', owns: [], boundaries: [], collaborators: [], principles: [] },
});
const conversations = createCodingConversationStore(projectPath);
const conversation = await conversations.create({
agentId: agent.id, title: 'Existing work', model: null, modelResolution: 'required',
});
const conversationPath = path.join(projectPath, '.makelore', 'conversations.json');
const originalConversations = await readFile(conversationPath, 'utf8');
const configPath = path.join(projectPath, '.makelore', 'project.json');
const originalConfig = await readFile(configPath, 'utf8');
await writeFile(path.join(projectPath, 'knowledge', 'notes.md'), 'Keep my project notes');
await projects.removeProject(created.project.id);
expect(await projects.listProjects()).toEqual([]);
expect(await projects.getActiveProject()).toBeNull();
const reopened = await projects.createProject(input);
expect(reopened.config.projectId).toBe(PROJECT_ID);
expect(reopened.config.projectType).toBe('custom');
expect(reopened.config.agents).toEqual([agent]);
expect(reopened.knowledgeFiles).toEqual(['notes.md']);
expect(await projects.getActiveProject()).toEqual(reopened.project);
expect(await projects.listProjects()).toEqual([reopened.project]);
expect(await readFile(configPath, 'utf8')).toBe(originalConfig);
expect(await readFile(conversationPath, 'utf8')).toBe(originalConversations);
expect((await createCodingConversationStore(reopened.project.path).read()).conversations).toEqual([conversation]);
expect(await readFile(path.join(projectPath, 'knowledge', 'notes.md'), 'utf8')).toBe('Keep my project notes');
const repeated = await projects.createProject(input);
expect(repeated.project.id).toBe(reopened.project.id);
expect(await projects.listProjects()).toEqual([repeated.project]);
});
it('keeps child-directory collisions and explicit identity binding as creation errors', async () => {
const parentPath = await makeRoot();
const projectPath = path.join(parentPath, 'existing');
const projects = new CodingProjectService(makeStore(), { createProjectId: () => PROJECT_ID });
const created = await projects.createProject({ projectPath, identity: { kind: 'create' } });
await projects.removeProject(created.project.id);
await expect(projects.createProject({
parentPath, projectName: 'existing', identity: { kind: 'create' },
})).rejects.toMatchObject({ status: 409, code: 'CODING_PROJECT_ALREADY_EXISTS' });
await expect(projects.createProject({
projectPath, identity: { kind: 'bind', projectId: NEXT_PROJECT_ID },
})).rejects.toMatchObject({ status: 409, code: 'CODING_PROJECT_ALREADY_EXISTS' });
expect(await projects.listProjects()).toEqual([]);
expect(await readCodingProjectConfigV2(projectPath)).toMatchObject({ config: { projectId: PROJECT_ID } });
});
it('reports invalid existing metadata without overwriting or registering it', async () => {
const projectPath = await makeRoot();
await mkdir(path.join(projectPath, '.makelore'));
const configPath = path.join(projectPath, '.makelore', 'project.json');
await writeFile(configPath, '{invalid json');
const projects = new CodingProjectService(makeStore());
await expect(projects.createProject({ projectPath, identity: { kind: 'create' } }))
.rejects.toMatchObject({ status: 409, code: 'CODING_PROJECT_CONFIG_INVALID' });
expect(await readFile(configPath, 'utf8')).toBe('{invalid json');
expect(await projects.listProjects()).toEqual([]);
});
it('reuses the existing identity-backfill path for a selected legacy project', async () => {
const projectPath = await makeRoot();
const store = makeStore();
const local = await createLocalCodingProject({ projectPath, now: CREATED }, store);
const projects = new CodingProjectService(store, { createProjectId: () => PROJECT_ID });
await projects.removeProject(local.project.id);
const reopened = await projects.createProject({ projectPath, identity: { kind: 'create' } });
expect(reopened.config).toMatchObject({ projectId: PROJECT_ID, createdAt: CREATED });
expect(await readCodingProjectConfigV2(projectPath)).toMatchObject({ config: { projectId: PROJECT_ID } });
});
it.each(['interactive_ai_app', 'custom'] as const)(
'creates only project-owned metadata for %s projects',
async (projectType) => {