diff --git a/.project-docs/30-worklog/tasks/20260826-ml01-project-identity-4d8a7c21.md b/.project-docs/30-worklog/tasks/20260826-ml01-project-identity-4d8a7c21.md new file mode 100644 index 0000000..8b4ec6e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml01-project-identity-4d8a7c21.md @@ -0,0 +1,118 @@ +# Task: Implement ML-01 project identity contract + +## Identity + +- Task ID: 20260826-ml01-project-identity-4d8a7c21 +- Mode: Feature +- Branch: codex/20260826-ml01-project-identity-4d8a7c21-ml01-project-identity +- Worktree: D:\Datas\OthersProjects\makelore-ml01-project-identity-4d8a7c21 +- Base commit: 7e54b8fbda1899b73334d7c3e732ce8e73460ed8 +- Owner: ml01-project-identity +- Status: Ready for integration + +## Scope + +- Implement ML-01 durable project identity core from the accepted MakeLore Data + Service P0 specification, sections 5.1 and 8.1/8.3-8.5, on the exact base + `7e54b8fbda1899b73334d7c3e732ce8e73460ed8`. +- Own only the ticket paths: shared project contracts; project config, + project service, migration, coding-project Host routes and composition + identity callback wiring; Pi release-proof create fixtures; and identity + focused/direct create-project tests required by the new request contract. +- Deliver optional canonical `projectId` persistence, required create/bind + creation identity, one-time legacy resolution, immutable ordinary saves, + confirmed independent-copy identity rotation, one centralized active-real- + project-with-identity helper, and atomic preview/resource invalidation. + +## Intent And Constraints + +- Work in the isolated ticket worktree and branch owned by this task. Do not + modify the occupied root `main` worktree or absorb changes from other tasks. +- Preserve local `CodingProjectSummary.id`, normalized-path reuse/index + behavior, Pi local project/session identifiers, and the `opencode-projects` + electron-store compatibility name. None may become the durable cloud ID. +- Do not add Data Service cloud/Host calls, Firebase compatibility, published + runtime support, generic operation multiplexing, or a filesystem-copy engine. +- New creation must require an explicit `{kind: "create"|"bind"}` identity; + Main generates `crypto.randomUUID()` for create and validates canonical + lowercase hyphenated UUIDs for bind. Legacy config opening/migration must + remain identity-free until a dedicated identity operation. +- Ordinary config saves preserve an existing identity and reject changes or + clear attempts; only the identity-resolution or confirmed independent-copy + operation may write it. Preview invalidation runs before the atomic metadata + write and callback failure must leave config unchanged. +- The centralized active-real-project helper must validate indexed active + project, resolved/stat'ed directory, `.niancode/project.json`, durable ID, + and trusted-context path equality without accepting model/tool owner or ID. + +## Planning Gate + +- Result: Passed on 2026-08-26 after Concurrent Task Gate isolation. +- Task identity, exact base, branch, worktree, and owner match the local + `task_context.py status --json` record. +- Loaded MakeLore AGENTS, required project-memory entry documents, accepted + architecture/domain/decision/evidence/commitment material, the cross-repo + Data Service spec sections 5.1 and 8.1/8.3-8.5, and the ML-01 ticket plan. +- Active peer review found the parent client coordinator task with the same + overall program but no semantic conflict; other planning records were either + placeholder/unknown or unrelated. The root integration record is completed; + root remains out of scope per the user handoff. +- Project positioning is still a template placeholder and therefore stale + context, not a source of product behavior; the accepted spec and contract + are the authoritative task inputs. + +## Implementation Plan + +1. Inspect the exact-base project identity/config/migration/service/route, + composition, Pi release-proof, and affected test seams; map all direct + create callers before editing. +2. Add contract/config normalization and migration-preserving optional identity, + then enforce required create identity and atomic creation semantics. +3. Add immutable save and one-time identity-resolution/independent-copy + operations, centralized active-real-project helper, route wiring, callback + invalidation, and release-proof/test fixtures. +4. Run focused identity tests plus typecheck and relevant lint/build checks; + inspect the diff for ownership boundaries and task-specific regressions. +5. Update this record with actual outcome/verification, run doc drift and + complete the task context, then commit one implementation commit. + +## Outcome + +- Implemented ML-01 in the isolated client worktree. V2 project metadata now + optionally persists a canonical lowercase hyphenated UUID without silently + backfilling legacy projects; Main-owned creation requires an explicit + create/bind choice and validates/generates the durable ID before any project + directory/config write. Ordinary saves preserve identity immutability. +- Added one-time legacy resolution and confirmed independent-copy operations, + exact Host routes, callback-before-atomic-write sequencing, resource refresh + hooks, centralized active-real-project-with-identity validation, and the + affected Pi release-proof/direct create fixtures. +- Preserved local summary/store IDs, migration omission, normalized-path + behavior, local Pi identifiers, and the existing project-store compatibility + name. No cloud/data-service calls or filesystem-copy engine were added. + +## Verification + +- `tsc --noEmit` passed with the bundled Node/TypeScript runtime. +- Focused Vitest passed: `coding-project-identity.test.ts` (8 tests), + `coding-projects-migration.test.ts` (8 tests), + `coding-core-routes.test.ts` (28 tests), and + `coding-projects-schema-v2.test.ts` (4 tests); 48 tests total. +- Focused ESLint passed for all modified implementation and test files. +- `git diff --check` passed. +- The initial dependency install attempted to add an approval note to + `pnpm-workspace.yaml`; it was removed immediately and is not part of this + task's diff. + +## Follow-ups + +- ML-02 must project the required identity choice into the Renderer create and + legacy/independent-copy UX. ML-03 must use + `requireActiveRealProjectWithIdentity()` for all Data Service callers. +- Parent merger should integrate this commit after confirming the exact + post-ML-00 base and rerun the repository-wide client gates. + +## Promotion Candidates + +- None: this ticket implements the accepted identity contract without a new + project-memory decision or canonical-document change. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index e6d4fd0..2aa3d52 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -178,6 +178,9 @@ export function createCodingComposition( .catch(() => []); for (const conversation of conversations) registry.forget(conversation.id); }, + onProjectIdentityChanging: async (project) => { + await options.browser.close(project.path); + }, onProjectDeactivated: async (project, reason) => { const conversations = await conversationStoreForProject(project.path).read() .then((file) => file.conversations) diff --git a/electron/api/routes/coding-projects.ts b/electron/api/routes/coding-projects.ts index b836ee9..7fe6d5a 100644 --- a/electron/api/routes/coding-projects.ts +++ b/electron/api/routes/coding-projects.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { ProjectType } from '../../../shared/project-config'; +import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; import type { CodingProjectConfigSnapshot } from '../../coding-projects/project-service'; import type { CodingProject } from '../../coding-projects/project-store'; import type { HostApiContext } from '../context'; @@ -65,10 +66,41 @@ export async function handleCodingProjectRoutes( parentPath?: string; projectName?: string; projectType?: ProjectType; + identity: ProjectIdentityChoice; }>(req); sendJson(res, 201, { snapshot: publicProjectSnapshot(await projects.createProject(body)) }); return true; } + if (url.pathname === '/api/coding/projects/identity' && req.method === 'POST') { + const body = await parseJsonBody<{ + localProjectId?: string; + identity: ProjectIdentityChoice; + }>(req); + sendJson(res, 200, { + snapshot: publicProjectSnapshot( + await projects.resolveProjectIdentity( + body.localProjectId ?? '', + body.identity, + ), + ), + }); + return true; + } + if (url.pathname === '/api/coding/projects/identity/independent-copy' && req.method === 'POST') { + const body = await parseJsonBody<{ + localProjectId?: string; + confirmed: unknown; + }>(req); + sendJson(res, 200, { + snapshot: publicProjectSnapshot( + await projects.makeProjectIndependentCopy( + body.localProjectId ?? '', + body.confirmed as true, + ), + ), + }); + return true; + } if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') { const body = await parseJsonBody<{ projectId?: string }>(req); await projects.removeProject(body.projectId ?? ''); diff --git a/electron/api/routes/coding-route-errors.ts b/electron/api/routes/coding-route-errors.ts index 0cf112a..5fe5618 100644 --- a/electron/api/routes/coding-route-errors.ts +++ b/electron/api/routes/coding-route-errors.ts @@ -5,6 +5,8 @@ import { sendJson } from '../route-utils'; const FIXED_CODING_ERROR_MESSAGES: Readonly> = { CODING_ACTIVE_PROJECT_REQUIRED: '请先选择一个编程项目。', + CODING_ACTIVE_PROJECT_INVALID: '当前编程项目不可用,请重新打开。', + CODING_ACTIVE_PROJECT_PATH_MISMATCH: '当前编程项目路径已变化,请重新打开。', CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。', CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。', CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。', @@ -19,6 +21,8 @@ const FIXED_CODING_ERROR_MESSAGES: Readonly> = { CODING_PROJECT_ALREADY_EXISTS: '该编程项目已经存在。', CODING_PROJECT_CONFIG_INVALID: '项目配置无效,请检查后重试。', CODING_PROJECT_IDENTITY_IMMUTABLE: '项目创建标识不能修改。', + CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED: '设为独立副本需要明确确认。', + CODING_PROJECT_IDENTITY_REQUIRED: '请先为该编程项目选择项目 ID。', CODING_PROJECT_NOT_FOUND: '指定的编程项目不存在。', CODING_PROJECT_REQUEST_INVALID: '项目请求无效,请检查输入。', CODING_PROJECT_TYPE_IMMUTABLE: '项目类型创建后不能修改。', diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts index 8386ce1..cfb03aa 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -9,6 +9,7 @@ import { import type { CodingProjectAgent, CodingProjectConfig, + ProjectIdentityChoice, } from '../../shared/coding-project-contracts'; import type { ConversationModelState, @@ -47,6 +48,24 @@ export interface CreateCodingProjectAgentInput { pinned?: boolean; } +const CANONICAL_PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +export function isCanonicalCodingProjectId(value: unknown): value is string { + return typeof value === 'string' && CANONICAL_PROJECT_ID_PATTERN.test(value); +} + +export function normalizeProjectIdentityChoice(value: unknown): ProjectIdentityChoice { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Project identity choice is invalid'); + } + const record = value as { kind?: unknown; projectId?: unknown }; + if (record.kind === 'create') return { kind: 'create' }; + if (record.kind === 'bind' && isCanonicalCodingProjectId(record.projectId)) { + return { kind: 'bind', projectId: record.projectId }; + } + throw new Error('Project identity choice is invalid'); +} + const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/; const THINKING_LEVELS = new Set([ @@ -182,6 +201,9 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon const record = value as Partial; if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema'); if (!isProjectType(record.projectType)) throw new Error('Invalid project type'); + if (record.projectId !== undefined && !isCanonicalCodingProjectId(record.projectId)) { + throw new Error('Project identity is invalid'); + } if (typeof record.initialized !== 'boolean') throw new Error('Project initialized state is invalid'); if (record.knowledgeDirectory !== 'knowledge') throw new Error('Project knowledge directory is invalid'); if (!Array.isArray(record.agents)) throw new Error('Project Agents must be an array'); @@ -199,6 +221,7 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon return { schemaVersion: 2, projectType: record.projectType, + ...(record.projectId !== undefined ? { projectId: record.projectId } : {}), initialized: record.initialized === true, agents, knowledgeDirectory: 'knowledge', @@ -211,10 +234,15 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon export function createCodingProjectConfigV2( now = new Date().toISOString(), projectType: ProjectType = 'custom', + projectId?: string, ): CodingProjectConfigV2 { + if (projectId !== undefined && !isCanonicalCodingProjectId(projectId)) { + throw new Error('Project identity is invalid'); + } return { schemaVersion: 2, projectType, + ...(projectId !== undefined ? { projectId } : {}), initialized: false, agents: [], knowledgeDirectory: 'knowledge', @@ -252,6 +280,7 @@ export async function createCodingProjectMetadata( projectPath: string, options: { projectType?: ProjectType; + projectId?: string; now?: string; writer?: JsonFileWriter; } = {}, @@ -262,7 +291,7 @@ export async function createCodingProjectMetadata( } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } - const config = createCodingProjectConfigV2(options.now, options.projectType); + const config = createCodingProjectConfigV2(options.now, options.projectType, options.projectId); await Promise.all([ mkdir(path.join(projectPath, '.niancode'), { recursive: true }), mkdir(path.join(projectPath, 'knowledge'), { recursive: true }), diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts index 6d45484..711faff 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -1,13 +1,17 @@ -import { mkdir, readdir, stat, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readdir, realpath, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { isProjectType, type ProjectType } from '../../shared/project-config'; +import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts'; import { createCodingConversationStore, type CodingConversationV2, } from './conversation-store'; import { acknowledgeLegacyConversationNotice, + isCanonicalCodingProjectId, normalizeCodingProjectConfigV2, + normalizeProjectIdentityChoice, readCodingProjectConfigV2, writeCodingProjectConfigV2, type CodingProjectConfigV2, @@ -18,6 +22,7 @@ import { } from './migration'; import { createLocalCodingProject, + normalizeCodingProjectPath, type CodingProject, type CodingProjectStore, } from './project-store'; @@ -50,6 +55,7 @@ export interface CreateCodingProjectRequest { parentPath?: string; projectName?: string; projectType?: ProjectType; + identity: ProjectIdentityChoice; } export interface CodingProjectConfigSnapshot { @@ -59,7 +65,14 @@ export interface CodingProjectConfigSnapshot { } export interface CodingProjectServiceOptions { + createProjectId?: () => string; + now?: () => string; onResourcesChanged?(project: CodingProject): Promise | void; + onProjectIdentityChanging?( + project: CodingProject, + previousProjectId: string | undefined, + nextProjectId: string, + ): Promise | void; onProjectDeactivated?( project: CodingProject, reason: 'project_deactivated' | 'project_removed', @@ -94,12 +107,40 @@ function assertStableConfig(previous: CodingProjectConfigV2, next: CodingProject if (next.createdAt !== previous.createdAt) { throw new CodingProjectServiceError(409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', 'Project creation identity cannot be changed'); } + if (next.projectId !== previous.projectId) { + throw new CodingProjectServiceError(409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', 'Project creation identity cannot be changed'); + } const nextIds = new Set(next.agents.map(({ id }) => id)); if (previous.agents.some(({ id }) => !nextIds.has(id))) { throw new CodingProjectServiceError(409, 'CODING_AGENT_ID_IMMUTABLE', 'Existing Agent ids must be preserved'); } } +function resolveProjectIdentity( + identity: unknown, + createProjectId: () => string, +): string { + let choice: ProjectIdentityChoice; + try { + choice = normalizeProjectIdentityChoice(identity); + } catch { + throw new CodingProjectServiceError( + 400, + 'CODING_PROJECT_REQUEST_INVALID', + 'Project identity choice is invalid', + ); + } + const projectId = choice.kind === 'create' ? createProjectId() : choice.projectId; + if (!isCanonicalCodingProjectId(projectId)) { + throw new CodingProjectServiceError( + 400, + 'CODING_PROJECT_REQUEST_INVALID', + 'Project identity is invalid', + ); + } + return projectId; +} + export class CodingProjectService { private readonly conversationStores = new Map< string, @@ -107,6 +148,7 @@ export class CodingProjectService { >(); private activeTransitionTail = Promise.resolve(); private readonly migrationFlights = new Map>(); + private readonly identityTransitionTails = new Map>(); constructor( private readonly store: CodingProjectStore, @@ -133,6 +175,67 @@ export class CodingProjectService { return project; } + async requireActiveRealProjectWithIdentity(trustedProjectPath?: string): Promise<{ + project: CodingProject; + path: string; + projectId: string; + }> { + const project = await this.requireActiveProject(); + let projectPath: string; + try { + projectPath = await realpath(path.resolve(project.path)); + } catch { + throw new CodingProjectServiceError( + 409, + 'CODING_ACTIVE_PROJECT_INVALID', + 'The active coding project directory is unavailable', + ); + } + const entry = await stat(projectPath).catch(() => null); + if (!entry?.isDirectory()) { + throw new CodingProjectServiceError( + 409, + 'CODING_ACTIVE_PROJECT_INVALID', + 'The active coding project directory is unavailable', + ); + } + if (trustedProjectPath !== undefined) { + let trustedRealPath: string; + try { + trustedRealPath = await realpath(path.resolve(trustedProjectPath)); + } catch { + throw new CodingProjectServiceError( + 409, + 'CODING_ACTIVE_PROJECT_PATH_MISMATCH', + 'The trusted coding project path does not match the active project', + ); + } + if (normalizeCodingProjectPath(trustedRealPath) !== normalizeCodingProjectPath(projectPath)) { + throw new CodingProjectServiceError( + 409, + 'CODING_ACTIVE_PROJECT_PATH_MISMATCH', + 'The trusted coding project path does not match the active project', + ); + } + } + const config = await this.readCurrentConfig(projectPath); + if (!config) { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_CONFIG_INVALID', + 'Coding project configuration is unavailable', + ); + } + if (!config.projectId) { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_IDENTITY_REQUIRED', + 'A durable coding project identity is required', + ); + } + return { project, path: projectPath, projectId: config.projectId }; + } + async getProject(projectId: string): Promise { const id = projectId.trim(); const project = (await this.store.listProjects()).find((candidate) => candidate.id === id); @@ -159,6 +262,10 @@ export class CodingProjectService { } async createProject(input: CreateCodingProjectRequest): Promise { + const projectId = resolveProjectIdentity( + input.identity, + this.options.createProjectId ?? randomUUID, + ); const selectedPath = input.projectPath?.trim(); if (selectedPath && (input.parentPath?.trim() || input.projectName?.trim())) { throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path input is ambiguous'); @@ -184,6 +291,7 @@ export class CodingProjectService { return await this.transitionActiveProject(async () => { const { project, config } = await createLocalCodingProject({ projectPath, + projectId, ...(input.projectType ? { projectType: input.projectType } : {}), }, this.store); const snapshot = { project, config, knowledgeFiles: [] }; @@ -272,6 +380,97 @@ export class CodingProjectService { }; } + async resolveProjectIdentity( + localProjectId: string, + identity: ProjectIdentityChoice, + ): Promise { + const project = await this.getProject(localProjectId); + return await this.serializeIdentityTransition(project.path, async () => { + const current = await this.getConfig(project.id); + if (current.config.projectId !== undefined) { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_IDENTITY_IMMUTABLE', + 'Project creation identity cannot be changed', + ); + } + const nextProjectId = resolveProjectIdentity( + identity, + this.options.createProjectId ?? randomUUID, + ); + await this.options.onProjectIdentityChanging?.( + project, + undefined, + nextProjectId, + ); + const next = { + ...current.config, + projectId: nextProjectId, + updatedAt: this.options.now?.() ?? new Date().toISOString(), + }; + try { + await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next); + } catch (error) { + storageFailure(error); + } + await this.options.onResourcesChanged?.(project); + return { + project: current.project, + config: next, + knowledgeFiles: await this.listKnowledgeFiles(current.project.path), + }; + }); + } + + async makeProjectIndependentCopy( + localProjectId: string, + confirmed: true, + ): Promise { + if (confirmed !== true) { + throw new CodingProjectServiceError( + 400, + 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED', + 'Independent copy requires explicit confirmation', + ); + } + const project = await this.getProject(localProjectId); + return await this.serializeIdentityTransition(project.path, async () => { + const current = await this.getConfig(project.id); + if (current.config.projectId === undefined) { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_IDENTITY_REQUIRED', + 'A durable coding project identity is required', + ); + } + const nextProjectId = resolveProjectIdentity( + { kind: 'create' }, + this.options.createProjectId ?? randomUUID, + ); + await this.options.onProjectIdentityChanging?.( + project, + current.config.projectId, + nextProjectId, + ); + const next = { + ...current.config, + projectId: nextProjectId, + updatedAt: this.options.now?.() ?? new Date().toISOString(), + }; + try { + await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next); + } catch (error) { + storageFailure(error); + } + await this.options.onResourcesChanged?.(project); + return { + project: current.project, + config: next, + knowledgeFiles: await this.listKnowledgeFiles(current.project.path), + }; + }); + } + async addKnowledgeFile(input: { projectId: string; fileName: string; @@ -343,6 +542,21 @@ export class CodingProjectService { return { project, conversation }; } + private serializeIdentityTransition( + projectPath: string, + operation: () => Promise, + ): Promise { + const previous = this.identityTransitionTails.get(projectPath) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const tail = result.then(() => undefined, () => undefined); + this.identityTransitionTails.set(projectPath, tail); + return result.finally(() => { + if (this.identityTransitionTails.get(projectPath) === tail) { + this.identityTransitionTails.delete(projectPath); + } + }); + } + private async listKnowledgeFiles(projectPath: string): Promise { try { const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true }); diff --git a/electron/coding-projects/project-store.ts b/electron/coding-projects/project-store.ts index aefe4c6..4514480 100644 --- a/electron/coding-projects/project-store.ts +++ b/electron/coding-projects/project-store.ts @@ -196,12 +196,14 @@ export async function createLocalCodingProject( input: { projectPath: string; projectType?: ProjectType; + projectId?: string; now?: string; }, store: CodingProjectStore, ): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> { const config = await createCodingProjectMetadata(input.projectPath, { projectType: input.projectType, + projectId: input.projectId, now: input.now, }); const project = await store.openFolder(input.projectPath); diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index 9294949..23bd3e0 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -1436,7 +1436,10 @@ export async function startFinalAsarProxyCompositionProof(input: { PROXY_PROOF_MODEL_ID, )); await providerService.setDefaultAccount(PROOF_ACCOUNT_ID); - const project = await input.composition.projects.createProject({ projectPath: input.projectPath }); + const project = await input.composition.projects.createProject({ + projectPath: input.projectPath, + identity: { kind: 'create' }, + }); projectId = project.project.id; await createCodingProjectAgent(input.projectPath, { id: PROOF_AGENT_ID, @@ -1777,7 +1780,10 @@ export async function startFinalAsarResilienceProof(input: { keepRecentTokens: 1, }, }, null, 2), 'utf8'); - const project = await input.composition.projects.createProject({ projectPath: input.projectPath }); + const project = await input.composition.projects.createProject({ + projectPath: input.projectPath, + identity: { kind: 'create' }, + }); projectId = project.project.id; await createCodingProjectAgent(input.projectPath, { id: PROOF_AGENT_ID, diff --git a/shared/coding-project-contracts.ts b/shared/coding-project-contracts.ts index 2ad514a..e5ea183 100644 --- a/shared/coding-project-contracts.ts +++ b/shared/coding-project-contracts.ts @@ -9,6 +9,10 @@ export interface CodingProjectSummary { lastOpenedAt: string; } +export type ProjectIdentityChoice = + | { kind: 'create' } + | { kind: 'bind'; projectId: string }; + export interface CodingProjectAgent extends ConversationModelState { id: string; avatarId: string; @@ -29,6 +33,7 @@ export interface CodingProjectAgent extends ConversationModelState { export interface CodingProjectConfig { schemaVersion: 2; projectType: ProjectType; + projectId?: string; initialized: boolean; agents: CodingProjectAgent[]; knowledgeDirectory: 'knowledge'; diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index b28cc80..0c6fc52 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -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) { diff --git a/tests/unit/coding-project-identity.test.ts b/tests/unit/coding-project-identity.test.ts new file mode 100644 index 0000000..4d0b28a --- /dev/null +++ b/tests/unit/coding-project-identity.test.ts @@ -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 { + 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' }, + }); + }); +}); diff --git a/tests/unit/coding-projects-migration.test.ts b/tests/unit/coding-projects-migration.test.ts index 0d02816..b995678 100644 --- a/tests/unit/coding-projects-migration.test.ts +++ b/tests/unit/coding-projects-migration.test.ts @@ -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',