feat(coding): add durable project identity core
This commit is contained in:
@@ -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.
|
||||||
@@ -178,6 +178,9 @@ export function createCodingComposition(
|
|||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
for (const conversation of conversations) registry.forget(conversation.id);
|
for (const conversation of conversations) registry.forget(conversation.id);
|
||||||
},
|
},
|
||||||
|
onProjectIdentityChanging: async (project) => {
|
||||||
|
await options.browser.close(project.path);
|
||||||
|
},
|
||||||
onProjectDeactivated: async (project, reason) => {
|
onProjectDeactivated: async (project, reason) => {
|
||||||
const conversations = await conversationStoreForProject(project.path).read()
|
const conversations = await conversationStoreForProject(project.path).read()
|
||||||
.then((file) => file.conversations)
|
.then((file) => file.conversations)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import type { ProjectType } from '../../../shared/project-config';
|
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 { CodingProjectConfigSnapshot } from '../../coding-projects/project-service';
|
||||||
import type { CodingProject } from '../../coding-projects/project-store';
|
import type { CodingProject } from '../../coding-projects/project-store';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
@@ -65,10 +66,41 @@ export async function handleCodingProjectRoutes(
|
|||||||
parentPath?: string;
|
parentPath?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
projectType?: ProjectType;
|
projectType?: ProjectType;
|
||||||
|
identity: ProjectIdentityChoice;
|
||||||
}>(req);
|
}>(req);
|
||||||
sendJson(res, 201, { snapshot: publicProjectSnapshot(await projects.createProject(body)) });
|
sendJson(res, 201, { snapshot: publicProjectSnapshot(await projects.createProject(body)) });
|
||||||
return true;
|
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') {
|
if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') {
|
||||||
const body = await parseJsonBody<{ projectId?: string }>(req);
|
const body = await parseJsonBody<{ projectId?: string }>(req);
|
||||||
await projects.removeProject(body.projectId ?? '');
|
await projects.removeProject(body.projectId ?? '');
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { sendJson } from '../route-utils';
|
|||||||
|
|
||||||
const FIXED_CODING_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
const FIXED_CODING_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||||
CODING_ACTIVE_PROJECT_REQUIRED: '请先选择一个编程项目。',
|
CODING_ACTIVE_PROJECT_REQUIRED: '请先选择一个编程项目。',
|
||||||
|
CODING_ACTIVE_PROJECT_INVALID: '当前编程项目不可用,请重新打开。',
|
||||||
|
CODING_ACTIVE_PROJECT_PATH_MISMATCH: '当前编程项目路径已变化,请重新打开。',
|
||||||
CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。',
|
CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。',
|
||||||
CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。',
|
CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。',
|
||||||
CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。',
|
CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。',
|
||||||
@@ -19,6 +21,8 @@ const FIXED_CODING_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
|||||||
CODING_PROJECT_ALREADY_EXISTS: '该编程项目已经存在。',
|
CODING_PROJECT_ALREADY_EXISTS: '该编程项目已经存在。',
|
||||||
CODING_PROJECT_CONFIG_INVALID: '项目配置无效,请检查后重试。',
|
CODING_PROJECT_CONFIG_INVALID: '项目配置无效,请检查后重试。',
|
||||||
CODING_PROJECT_IDENTITY_IMMUTABLE: '项目创建标识不能修改。',
|
CODING_PROJECT_IDENTITY_IMMUTABLE: '项目创建标识不能修改。',
|
||||||
|
CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED: '设为独立副本需要明确确认。',
|
||||||
|
CODING_PROJECT_IDENTITY_REQUIRED: '请先为该编程项目选择项目 ID。',
|
||||||
CODING_PROJECT_NOT_FOUND: '指定的编程项目不存在。',
|
CODING_PROJECT_NOT_FOUND: '指定的编程项目不存在。',
|
||||||
CODING_PROJECT_REQUEST_INVALID: '项目请求无效,请检查输入。',
|
CODING_PROJECT_REQUEST_INVALID: '项目请求无效,请检查输入。',
|
||||||
CODING_PROJECT_TYPE_IMMUTABLE: '项目类型创建后不能修改。',
|
CODING_PROJECT_TYPE_IMMUTABLE: '项目类型创建后不能修改。',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CodingProjectAgent,
|
CodingProjectAgent,
|
||||||
CodingProjectConfig,
|
CodingProjectConfig,
|
||||||
|
ProjectIdentityChoice,
|
||||||
} from '../../shared/coding-project-contracts';
|
} from '../../shared/coding-project-contracts';
|
||||||
import type {
|
import type {
|
||||||
ConversationModelState,
|
ConversationModelState,
|
||||||
@@ -47,6 +48,24 @@ export interface CreateCodingProjectAgentInput {
|
|||||||
pinned?: boolean;
|
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 AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/;
|
const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/;
|
||||||
const THINKING_LEVELS = new Set<ConversationThinkingLevel>([
|
const THINKING_LEVELS = new Set<ConversationThinkingLevel>([
|
||||||
@@ -182,6 +201,9 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
|
|||||||
const record = value as Partial<CodingProjectConfigV2>;
|
const record = value as Partial<CodingProjectConfigV2>;
|
||||||
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
|
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
|
||||||
if (!isProjectType(record.projectType)) throw new Error('Invalid project type');
|
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 (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 (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');
|
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 {
|
return {
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
projectType: record.projectType,
|
projectType: record.projectType,
|
||||||
|
...(record.projectId !== undefined ? { projectId: record.projectId } : {}),
|
||||||
initialized: record.initialized === true,
|
initialized: record.initialized === true,
|
||||||
agents,
|
agents,
|
||||||
knowledgeDirectory: 'knowledge',
|
knowledgeDirectory: 'knowledge',
|
||||||
@@ -211,10 +234,15 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
|
|||||||
export function createCodingProjectConfigV2(
|
export function createCodingProjectConfigV2(
|
||||||
now = new Date().toISOString(),
|
now = new Date().toISOString(),
|
||||||
projectType: ProjectType = 'custom',
|
projectType: ProjectType = 'custom',
|
||||||
|
projectId?: string,
|
||||||
): CodingProjectConfigV2 {
|
): CodingProjectConfigV2 {
|
||||||
|
if (projectId !== undefined && !isCanonicalCodingProjectId(projectId)) {
|
||||||
|
throw new Error('Project identity is invalid');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
projectType,
|
projectType,
|
||||||
|
...(projectId !== undefined ? { projectId } : {}),
|
||||||
initialized: false,
|
initialized: false,
|
||||||
agents: [],
|
agents: [],
|
||||||
knowledgeDirectory: 'knowledge',
|
knowledgeDirectory: 'knowledge',
|
||||||
@@ -252,6 +280,7 @@ export async function createCodingProjectMetadata(
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
options: {
|
options: {
|
||||||
projectType?: ProjectType;
|
projectType?: ProjectType;
|
||||||
|
projectId?: string;
|
||||||
now?: string;
|
now?: string;
|
||||||
writer?: JsonFileWriter;
|
writer?: JsonFileWriter;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -262,7 +291,7 @@ export async function createCodingProjectMetadata(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw 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([
|
await Promise.all([
|
||||||
mkdir(path.join(projectPath, '.niancode'), { recursive: true }),
|
mkdir(path.join(projectPath, '.niancode'), { recursive: true }),
|
||||||
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),
|
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),
|
||||||
|
|||||||
@@ -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 path from 'node:path';
|
||||||
import { isProjectType, type ProjectType } from '../../shared/project-config';
|
import { isProjectType, type ProjectType } from '../../shared/project-config';
|
||||||
|
import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts';
|
||||||
import {
|
import {
|
||||||
createCodingConversationStore,
|
createCodingConversationStore,
|
||||||
type CodingConversationV2,
|
type CodingConversationV2,
|
||||||
} from './conversation-store';
|
} from './conversation-store';
|
||||||
import {
|
import {
|
||||||
acknowledgeLegacyConversationNotice,
|
acknowledgeLegacyConversationNotice,
|
||||||
|
isCanonicalCodingProjectId,
|
||||||
normalizeCodingProjectConfigV2,
|
normalizeCodingProjectConfigV2,
|
||||||
|
normalizeProjectIdentityChoice,
|
||||||
readCodingProjectConfigV2,
|
readCodingProjectConfigV2,
|
||||||
writeCodingProjectConfigV2,
|
writeCodingProjectConfigV2,
|
||||||
type CodingProjectConfigV2,
|
type CodingProjectConfigV2,
|
||||||
@@ -18,6 +22,7 @@ import {
|
|||||||
} from './migration';
|
} from './migration';
|
||||||
import {
|
import {
|
||||||
createLocalCodingProject,
|
createLocalCodingProject,
|
||||||
|
normalizeCodingProjectPath,
|
||||||
type CodingProject,
|
type CodingProject,
|
||||||
type CodingProjectStore,
|
type CodingProjectStore,
|
||||||
} from './project-store';
|
} from './project-store';
|
||||||
@@ -50,6 +55,7 @@ export interface CreateCodingProjectRequest {
|
|||||||
parentPath?: string;
|
parentPath?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
projectType?: ProjectType;
|
projectType?: ProjectType;
|
||||||
|
identity: ProjectIdentityChoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodingProjectConfigSnapshot {
|
export interface CodingProjectConfigSnapshot {
|
||||||
@@ -59,7 +65,14 @@ export interface CodingProjectConfigSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CodingProjectServiceOptions {
|
export interface CodingProjectServiceOptions {
|
||||||
|
createProjectId?: () => string;
|
||||||
|
now?: () => string;
|
||||||
onResourcesChanged?(project: CodingProject): Promise<void> | void;
|
onResourcesChanged?(project: CodingProject): Promise<void> | void;
|
||||||
|
onProjectIdentityChanging?(
|
||||||
|
project: CodingProject,
|
||||||
|
previousProjectId: string | undefined,
|
||||||
|
nextProjectId: string,
|
||||||
|
): Promise<void> | void;
|
||||||
onProjectDeactivated?(
|
onProjectDeactivated?(
|
||||||
project: CodingProject,
|
project: CodingProject,
|
||||||
reason: 'project_deactivated' | 'project_removed',
|
reason: 'project_deactivated' | 'project_removed',
|
||||||
@@ -94,12 +107,40 @@ function assertStableConfig(previous: CodingProjectConfigV2, next: CodingProject
|
|||||||
if (next.createdAt !== previous.createdAt) {
|
if (next.createdAt !== previous.createdAt) {
|
||||||
throw new CodingProjectServiceError(409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', 'Project creation identity cannot be changed');
|
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));
|
const nextIds = new Set(next.agents.map(({ id }) => id));
|
||||||
if (previous.agents.some(({ id }) => !nextIds.has(id))) {
|
if (previous.agents.some(({ id }) => !nextIds.has(id))) {
|
||||||
throw new CodingProjectServiceError(409, 'CODING_AGENT_ID_IMMUTABLE', 'Existing Agent ids must be preserved');
|
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 {
|
export class CodingProjectService {
|
||||||
private readonly conversationStores = new Map<
|
private readonly conversationStores = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -107,6 +148,7 @@ export class CodingProjectService {
|
|||||||
>();
|
>();
|
||||||
private activeTransitionTail = Promise.resolve();
|
private activeTransitionTail = Promise.resolve();
|
||||||
private readonly migrationFlights = new Map<string, Promise<CodingProjectConfigV2>>();
|
private readonly migrationFlights = new Map<string, Promise<CodingProjectConfigV2>>();
|
||||||
|
private readonly identityTransitionTails = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: CodingProjectStore,
|
private readonly store: CodingProjectStore,
|
||||||
@@ -133,6 +175,67 @@ export class CodingProjectService {
|
|||||||
return project;
|
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<CodingProject> {
|
async getProject(projectId: string): Promise<CodingProject> {
|
||||||
const id = projectId.trim();
|
const id = projectId.trim();
|
||||||
const project = (await this.store.listProjects()).find((candidate) => candidate.id === id);
|
const project = (await this.store.listProjects()).find((candidate) => candidate.id === id);
|
||||||
@@ -159,6 +262,10 @@ export class CodingProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createProject(input: CreateCodingProjectRequest): Promise<CodingProjectConfigSnapshot> {
|
async createProject(input: CreateCodingProjectRequest): Promise<CodingProjectConfigSnapshot> {
|
||||||
|
const projectId = resolveProjectIdentity(
|
||||||
|
input.identity,
|
||||||
|
this.options.createProjectId ?? randomUUID,
|
||||||
|
);
|
||||||
const selectedPath = input.projectPath?.trim();
|
const selectedPath = input.projectPath?.trim();
|
||||||
if (selectedPath && (input.parentPath?.trim() || input.projectName?.trim())) {
|
if (selectedPath && (input.parentPath?.trim() || input.projectName?.trim())) {
|
||||||
throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path input is ambiguous');
|
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 () => {
|
return await this.transitionActiveProject(async () => {
|
||||||
const { project, config } = await createLocalCodingProject({
|
const { project, config } = await createLocalCodingProject({
|
||||||
projectPath,
|
projectPath,
|
||||||
|
projectId,
|
||||||
...(input.projectType ? { projectType: input.projectType } : {}),
|
...(input.projectType ? { projectType: input.projectType } : {}),
|
||||||
}, this.store);
|
}, this.store);
|
||||||
const snapshot = { project, config, knowledgeFiles: [] };
|
const snapshot = { project, config, knowledgeFiles: [] };
|
||||||
@@ -272,6 +380,97 @@ export class CodingProjectService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveProjectIdentity(
|
||||||
|
localProjectId: string,
|
||||||
|
identity: ProjectIdentityChoice,
|
||||||
|
): Promise<CodingProjectConfigSnapshot> {
|
||||||
|
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<CodingProjectConfigSnapshot> {
|
||||||
|
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: {
|
async addKnowledgeFile(input: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -343,6 +542,21 @@ export class CodingProjectService {
|
|||||||
return { project, conversation };
|
return { project, conversation };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serializeIdentityTransition<T>(
|
||||||
|
projectPath: string,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<string[]> {
|
private async listKnowledgeFiles(projectPath: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true });
|
const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true });
|
||||||
|
|||||||
@@ -196,12 +196,14 @@ export async function createLocalCodingProject(
|
|||||||
input: {
|
input: {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
projectType?: ProjectType;
|
projectType?: ProjectType;
|
||||||
|
projectId?: string;
|
||||||
now?: string;
|
now?: string;
|
||||||
},
|
},
|
||||||
store: CodingProjectStore,
|
store: CodingProjectStore,
|
||||||
): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> {
|
): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> {
|
||||||
const config = await createCodingProjectMetadata(input.projectPath, {
|
const config = await createCodingProjectMetadata(input.projectPath, {
|
||||||
projectType: input.projectType,
|
projectType: input.projectType,
|
||||||
|
projectId: input.projectId,
|
||||||
now: input.now,
|
now: input.now,
|
||||||
});
|
});
|
||||||
const project = await store.openFolder(input.projectPath);
|
const project = await store.openFolder(input.projectPath);
|
||||||
|
|||||||
@@ -1436,7 +1436,10 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
|||||||
PROXY_PROOF_MODEL_ID,
|
PROXY_PROOF_MODEL_ID,
|
||||||
));
|
));
|
||||||
await providerService.setDefaultAccount(PROOF_ACCOUNT_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;
|
projectId = project.project.id;
|
||||||
await createCodingProjectAgent(input.projectPath, {
|
await createCodingProjectAgent(input.projectPath, {
|
||||||
id: PROOF_AGENT_ID,
|
id: PROOF_AGENT_ID,
|
||||||
@@ -1777,7 +1780,10 @@ export async function startFinalAsarResilienceProof(input: {
|
|||||||
keepRecentTokens: 1,
|
keepRecentTokens: 1,
|
||||||
},
|
},
|
||||||
}, null, 2), 'utf8');
|
}, 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;
|
projectId = project.project.id;
|
||||||
await createCodingProjectAgent(input.projectPath, {
|
await createCodingProjectAgent(input.projectPath, {
|
||||||
id: PROOF_AGENT_ID,
|
id: PROOF_AGENT_ID,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface CodingProjectSummary {
|
|||||||
lastOpenedAt: string;
|
lastOpenedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectIdentityChoice =
|
||||||
|
| { kind: 'create' }
|
||||||
|
| { kind: 'bind'; projectId: string };
|
||||||
|
|
||||||
export interface CodingProjectAgent extends ConversationModelState {
|
export interface CodingProjectAgent extends ConversationModelState {
|
||||||
id: string;
|
id: string;
|
||||||
avatarId: string;
|
avatarId: string;
|
||||||
@@ -29,6 +33,7 @@ export interface CodingProjectAgent extends ConversationModelState {
|
|||||||
export interface CodingProjectConfig {
|
export interface CodingProjectConfig {
|
||||||
schemaVersion: 2;
|
schemaVersion: 2;
|
||||||
projectType: ProjectType;
|
projectType: ProjectType;
|
||||||
|
projectId?: string;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
agents: CodingProjectAgent[];
|
agents: CodingProjectAgent[];
|
||||||
knowledgeDirectory: 'knowledge';
|
knowledgeDirectory: 'knowledge';
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ describe('PI-100 coding core Host contract', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const created = await composition.projects.createProject({ projectPath });
|
const created = await composition.projects.createProject({
|
||||||
|
projectPath,
|
||||||
|
identity: { kind: 'create' },
|
||||||
|
});
|
||||||
await createCodingProjectAgent(projectPath, {
|
await createCodingProjectAgent(projectPath, {
|
||||||
id: 'builder',
|
id: 'builder',
|
||||||
avatarId: 'avatar-01',
|
avatarId: 'avatar-01',
|
||||||
@@ -682,7 +685,10 @@ describe('PI-100 coding core Host contract', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const opened = await projects.openProject(projectRoots[1]!);
|
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.setActiveProject(first.project.id);
|
||||||
await projects.removeProject(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',
|
path: '/api/coding/projects/create',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ projectPath: createRoot }),
|
body: JSON.stringify({ projectPath: createRoot, identity: { kind: 'create' } }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
|
|||||||
290
tests/unit/coding-project-identity.test.ts
Normal file
290
tests/unit/coding-project-identity.test.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -177,6 +177,7 @@ describe('coding project v1 to v2 migration', () => {
|
|||||||
|
|
||||||
expect(result.status).toBe('migrated');
|
expect(result.status).toBe('migrated');
|
||||||
if (result.status !== 'migrated') throw new Error('Expected migrated result');
|
if (result.status !== 'migrated') throw new Error('Expected migrated result');
|
||||||
|
expect(result.config).not.toHaveProperty('projectId');
|
||||||
expect(result.config.agents).toEqual([
|
expect(result.config.agents).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'unique',
|
id: 'unique',
|
||||||
|
|||||||
Reference in New Issue
Block a user