// @vitest-environment node import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; 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, readCodingProjectConfigV2, writeCodingProjectConfigV2, } from '../../electron/coding-projects/project-config'; import { CodingProjectService } from '../../electron/coding-projects/project-service'; import { createCodingProjectStore, createLocalCodingProject, createMemoryCodingProjectStorage, normalizeCodingProjectPath, } from '../../electron/coding-projects/project-store'; const roots: string[] = []; const CREATED = '2026-08-26T00:00:00.000Z'; const UPDATED = '2026-08-26T00:01:00.000Z'; const PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const NEXT_PROJECT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); async function makeRoot(prefix = 'makelore-project-identity-'): Promise { const root = await mkdtemp(path.join(tmpdir(), prefix)); roots.push(root); return root; } function makeStore(localId = 'local-project-id') { return createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => localId, now: () => CREATED, }); } 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) => { const projectPath = await makeRoot(); const projects = new CodingProjectService(makeStore(), { createProjectId: () => PROJECT_ID, }); const created = await projects.createProject({ projectPath, projectType, identity: { kind: 'create' }, }); expect(created.config.projectType).toBe(projectType); expect((await readdir(projectPath)).sort()).toEqual(['.makelore', 'knowledge']); }, ); it.each(['mini_game', 'mini_program'] as const)( 'reads legacy %s metadata as the canonical interactive type without rewriting it', async (legacyProjectType) => { const projectPath = await makeRoot(); await mkdir(path.join(projectPath, '.makelore'), { recursive: true }); const configPath = path.join(projectPath, '.makelore', 'project.json'); const rawConfig = `${JSON.stringify({ ...createCodingProjectConfigV2(CREATED, 'interactive_ai_app'), projectType: legacyProjectType, }, null, 2)}\n`; await writeFile(configPath, rawConfig, 'utf8'); await expect(readCodingProjectConfigV2(projectPath)).resolves.toMatchObject({ status: 'valid', config: { projectType: 'interactive_ai_app' }, }); await expect(readFile(configPath, 'utf8')).resolves.toBe(rawConfig); }, ); it('preserves canonical identity and rejects noncanonical values without backfill', () => { const initialized = createCodingProjectConfigV2(CREATED, 'custom', PROJECT_ID); expect(initialized.projectId).toBe(PROJECT_ID); expect(isCanonicalCodingProjectId(PROJECT_ID)).toBe(true); const legacy = createCodingProjectConfigV2(CREATED, 'custom'); expect(legacy).not.toHaveProperty('projectId'); expect(normalizeCodingProjectConfigV2(legacy)).not.toHaveProperty('projectId'); for (const projectId of [ PROJECT_ID.toUpperCase(), `{${PROJECT_ID}}`, PROJECT_ID.replaceAll('-', ''), ` ${PROJECT_ID}`, 'not-a-project-id', null, ]) { expect(() => normalizeCodingProjectConfigV2({ ...legacy, projectId })) .toThrow('Project identity is invalid'); } }); it('requires an explicit create/bind choice and writes no config on invalid bind', async () => { const projectPath = await makeRoot(); const store = makeStore(); const createProjectId = vi.fn(() => PROJECT_ID); const identityChanging = vi.fn(); const projects = new CodingProjectService(store, { createProjectId, onProjectIdentityChanging: identityChanging, }); const created = await projects.createProject({ projectPath, identity: { kind: 'create' }, }); expect(created.config.projectId).toBe(PROJECT_ID); expect(createProjectId).toHaveBeenCalledTimes(1); expect(identityChanging).not.toHaveBeenCalled(); const invalidPath = path.join(await makeRoot('makelore-project-identity-parent-'), 'invalid'); await expect(projects.createProject({ projectPath: invalidPath, identity: { kind: 'bind', projectId: 'not-canonical' }, })).rejects.toMatchObject({ code: 'CODING_PROJECT_REQUEST_INVALID', status: 400, }); await expect(readCodingProjectConfigV2(invalidPath)).resolves.toEqual({ status: 'missing' }); await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' }); const boundPath = await makeRoot(); const bound = await projects.createProject({ projectPath: boundPath, identity: { kind: 'bind', projectId: NEXT_PROJECT_ID }, }); expect(bound.config.projectId).toBe(NEXT_PROJECT_ID); expect(createProjectId).toHaveBeenCalledTimes(1); }); it('resolves a legacy identity once and invalidates before writing resources', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, now: CREATED }, store); const events: string[] = []; const projects = new CodingProjectService(store, { createProjectId: () => PROJECT_ID, now: () => UPDATED, onProjectIdentityChanging: () => { events.push('invalidate'); }, writeConfig: async (filePath, value) => { events.push('write'); await writeCodingProjectConfigV2(filePath, value); }, onResourcesChanged: () => { events.push('resources'); }, }); expect(local.config).not.toHaveProperty('projectId'); const resolved = await projects.resolveProjectIdentity(local.project.id, { kind: 'create' }); expect(resolved.config.projectId).toBe(PROJECT_ID); expect(events).toEqual(['invalidate', 'write', 'resources']); await expect(projects.resolveProjectIdentity(local.project.id, { kind: 'bind', projectId: NEXT_PROJECT_ID, })).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409, }); expect((await projects.getConfig(local.project.id)).config.projectId).toBe(PROJECT_ID); }); it('automatically assigns one identity when a legacy project is read concurrently', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, now: CREATED }, store); const createProjectId = vi.fn(() => PROJECT_ID); const events: string[] = []; const projects = new CodingProjectService(store, { createProjectId, now: () => UPDATED, onProjectIdentityChanging: () => { events.push('invalidate'); }, writeConfig: async (filePath, value) => { events.push('write'); await writeCodingProjectConfigV2(filePath, value); }, onResourcesChanged: () => { events.push('resources'); }, }); const [first, second] = await Promise.all([ projects.getConfig(local.project.id), projects.getConfig(local.project.id), ]); expect(first.config.projectId).toBe(PROJECT_ID); expect(second.config.projectId).toBe(PROJECT_ID); expect(createProjectId).toHaveBeenCalledTimes(1); expect(events).toEqual(['invalidate', 'write', 'resources']); await expect(readCodingProjectConfigV2(projectPath)) .resolves.toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID, updatedAt: UPDATED } }); }); it('keeps ordinary saves immutable and supports a confirmed independent copy', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, projectId: PROJECT_ID, now: CREATED, }, store); const invalidating = vi.fn(); const projects = new CodingProjectService(store, { createProjectId: () => NEXT_PROJECT_ID, now: () => UPDATED, onProjectIdentityChanging: invalidating, }); const current = await projects.getConfig(local.project.id); await expect(projects.saveConfig(local.project.id, { ...current.config, projectId: undefined, })).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409 }); await expect(projects.saveConfig(local.project.id, { ...current.config, projectId: NEXT_PROJECT_ID, })).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409 }); await expect(projects.makeProjectIndependentCopy( local.project.id, false as unknown as true, )).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED', status: 400, }); const independent = await projects.makeProjectIndependentCopy(local.project.id, true); expect(independent.config.projectId).toBe(NEXT_PROJECT_ID); expect(independent.config.updatedAt).toBe(UPDATED); expect(invalidating).toHaveBeenCalledWith(local.project, PROJECT_ID, NEXT_PROJECT_ID); }); it('does not write a legacy identity when preview invalidation fails', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath }, store); const projects = new CodingProjectService(store, { createProjectId: () => PROJECT_ID, onProjectIdentityChanging: () => { throw new Error('preview close failed'); }, }); await expect(projects.resolveProjectIdentity(local.project.id, { kind: 'create' })) .rejects.toThrow('preview close failed'); const current = await readCodingProjectConfigV2(projectPath); expect(current).toMatchObject({ status: 'valid', config: { projectType: 'custom' } }); if (current.status === 'valid') expect(current.config).not.toHaveProperty('projectId'); }); it('retains identity across a move and raw folder copy', async () => { const parent = await makeRoot('makelore-project-identity-move-parent-'); const originalPath = path.join(parent, 'original'); await mkdir(originalPath); const store = makeStore(); const original = await createLocalCodingProject({ projectPath: originalPath, projectId: PROJECT_ID, now: CREATED, }, store); const movedPath = path.join(parent, 'moved'); await rename(originalPath, movedPath); const movedStore = makeStore('moved-local-project-id'); const moved = await movedStore.openFolder(movedPath); expect((await readCodingProjectConfigV2(moved.path)).status).toBe('valid'); const movedConfig = await readCodingProjectConfigV2(moved.path); expect(movedConfig).toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID } }); const copiedPath = path.join(parent, 'copied'); await mkdir(path.join(copiedPath, '.makelore'), { recursive: true }); await copyFile( path.join(movedPath, '.makelore', 'project.json'), path.join(copiedPath, '.makelore', 'project.json'), ); const copied = await readCodingProjectConfigV2(copiedPath); expect(copied).toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID } }); expect(original.project.id).not.toBe(moved.id); }); it('centralizes the active real path and durable identity checks', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, projectId: PROJECT_ID }, store); const projects = new CodingProjectService(store); const active = await projects.requireActiveRealProjectWithIdentity(projectPath); expect(active).toMatchObject({ project: local.project, projectId: PROJECT_ID }); expect(normalizeCodingProjectPath(active.path)).toBe( normalizeCodingProjectPath(await realpath(local.project.path)), ); const otherPath = await makeRoot('makelore-project-identity-other-'); await expect(projects.requireActiveRealProjectWithIdentity(otherPath)).rejects.toMatchObject({ code: 'CODING_ACTIVE_PROJECT_PATH_MISMATCH', status: 409, }); const legacyPath = await makeRoot('makelore-project-identity-legacy-'); const legacyStore = makeStore(); await createLocalCodingProject({ projectPath: legacyPath }, legacyStore); const legacyProjects = new CodingProjectService(legacyStore, { createProjectId: () => NEXT_PROJECT_ID, }); await expect(legacyProjects.requireActiveRealProjectWithIdentity()) .resolves.toMatchObject({ projectId: NEXT_PROJECT_ID }); await expect(readCodingProjectConfigV2(legacyPath)) .resolves.toMatchObject({ status: 'valid', config: { projectId: NEXT_PROJECT_ID } }); }); it('exposes identity resolution and independent-copy through the Host routes', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath }, store); const generatedIds = [PROJECT_ID, NEXT_PROJECT_ID]; const projects = new CodingProjectService(store, { createProjectId: () => generatedIds.shift() ?? 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', }); const context = { codingProducts: { projects, conversations: {} }, } as unknown as HostApiContext; const resolved = await dispatchHostApiRequest(context, { path: '/api/coding/projects/identity', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ localProjectId: local.project.id, identity: { kind: 'create' } }), }); expect(resolved).toMatchObject({ status: 200, json: { snapshot: { config: { projectId: PROJECT_ID } } }, }); const independent = await dispatchHostApiRequest(context, { path: '/api/coding/projects/identity/independent-copy', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ localProjectId: local.project.id, confirmed: true }), }); expect(independent.status).toBe(200); expect((independent.json as { snapshot: { config: { projectId: string } } }).snapshot.config.projectId) .not.toBe(PROJECT_ID); const rejected = await dispatchHostApiRequest(context, { path: '/api/coding/projects/identity/independent-copy', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ localProjectId: local.project.id, confirmed: false }), }); expect(rejected).toMatchObject({ status: 400, json: { code: 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED' }, }); }); });