feat(coding): add durable project identity core
This commit is contained in:
@@ -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<ConversationThinkingLevel>([
|
||||
@@ -182,6 +201,9 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
|
||||
const record = value as Partial<CodingProjectConfigV2>;
|
||||
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 }),
|
||||
|
||||
@@ -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> | void;
|
||||
onProjectIdentityChanging?(
|
||||
project: CodingProject,
|
||||
previousProjectId: string | undefined,
|
||||
nextProjectId: string,
|
||||
): Promise<void> | 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<string, Promise<CodingProjectConfigV2>>();
|
||||
private readonly identityTransitionTails = new Map<string, Promise<void>>();
|
||||
|
||||
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<CodingProject> {
|
||||
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<CodingProjectConfigSnapshot> {
|
||||
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<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: {
|
||||
projectId: string;
|
||||
fileName: string;
|
||||
@@ -343,6 +542,21 @@ export class CodingProjectService {
|
||||
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[]> {
|
||||
try {
|
||||
const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user