feat(coding): add durable project identity core

This commit is contained in:
2026-08-26 17:59:44 +08:00
parent 7e54b8fbda
commit 43f58fc72b
12 changed files with 717 additions and 7 deletions

View File

@@ -117,7 +117,10 @@ describe('PI-100 coding core Host contract', () => {
},
});
try {
const created = await composition.projects.createProject({ projectPath });
const created = await composition.projects.createProject({
projectPath,
identity: { kind: 'create' },
});
await createCodingProjectAgent(projectPath, {
id: 'builder',
avatarId: 'avatar-01',
@@ -682,7 +685,10 @@ describe('PI-100 coding core Host contract', () => {
});
const opened = await projects.openProject(projectRoots[1]!);
const created = await projects.createProject({ projectPath: projectRoots[2]! });
const created = await projects.createProject({
projectPath: projectRoots[2]!,
identity: { kind: 'create' },
});
await projects.setActiveProject(first.project.id);
await projects.removeProject(first.project.id);
@@ -716,7 +722,7 @@ describe('PI-100 coding core Host contract', () => {
path: '/api/coding/projects/create',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ projectPath: createRoot }),
body: JSON.stringify({ projectPath: createRoot, identity: { kind: 'create' } }),
}));
for (const response of responses) {

View File

@@ -0,0 +1,290 @@
// @vitest-environment node
import { copyFile, mkdir, mkdtemp, rename, rm, stat } 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 {
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<string> {
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('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('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, '.niancode'), { recursive: true });
await copyFile(
path.join(movedPath, '.niancode', 'project.json'),
path.join(copiedPath, '.niancode', '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(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);
await expect(new CodingProjectService(legacyStore).requireActiveRealProjectWithIdentity())
.rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_REQUIRED', status: 409 });
});
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' },
});
});
});

View File

@@ -177,6 +177,7 @@ describe('coding project v1 to v2 migration', () => {
expect(result.status).toBe('migrated');
if (result.status !== 'migrated') throw new Error('Expected migrated result');
expect(result.config).not.toHaveProperty('projectId');
expect(result.config.agents).toEqual([
expect.objectContaining({
id: 'unique',