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 { isCanonicalCodingProjectId, normalizeCodingProjectConfigV2, normalizeProjectIdentityChoice, readCodingProjectConfigV2, writeCodingProjectConfigV2, type CodingProjectConfigV2, } from './project-config'; import { createLocalCodingProject, normalizeCodingProjectPath, type CodingProject, type CodingProjectStore, } from './project-store'; const MAX_PROJECT_NAME = 100; const MAX_KNOWLEDGE_FILE_BYTES = 25 * 1024 * 1024; export class CodingProjectServiceError extends Error { constructor( readonly status: 400 | 404 | 409 | 500, readonly code: string, message: string, ) { super(message); this.name = 'CodingProjectServiceError'; } } function storageFailure(error: unknown): never { if (error instanceof CodingProjectServiceError) throw error; throw new CodingProjectServiceError( 500, 'CODING_STORAGE_WRITE_FAILED', 'Coding project data could not be persisted', ); } export interface CreateCodingProjectRequest { projectPath?: string; parentPath?: string; projectName?: string; projectType?: ProjectType; identity: ProjectIdentityChoice; } export interface CodingProjectConfigSnapshot { project: CodingProject; config: CodingProjectConfigV2; knowledgeFiles: string[]; } 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', ): Promise | void; createConversationStore?: typeof createCodingConversationStore; writeConfig?: typeof writeCodingProjectConfigV2; } function requiredAbsolutePath(value: string | undefined, label: string): string { const input = value?.trim() ?? ''; if (!input || !path.isAbsolute(input)) { throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', `${label} must be an absolute path`); } return path.resolve(input); } function projectChildPath(parentPath: string | undefined, projectName: string | undefined): string { const parent = requiredAbsolutePath(parentPath, 'Project parent path'); const name = projectName?.trim() ?? ''; if (!name || name.length > MAX_PROJECT_NAME || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0')) { throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project name is invalid'); } return path.join(parent, name); } function assertStableConfig(previous: CodingProjectConfigV2, next: CodingProjectConfigV2): void { if (next.projectType !== previous.projectType) { throw new CodingProjectServiceError(409, 'CODING_PROJECT_TYPE_IMMUTABLE', 'Project type cannot be changed'); } 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, ReturnType >(); private activeTransitionTail = Promise.resolve(); private readonly identityTransitionTails = new Map>(); constructor( private readonly store: CodingProjectStore, private readonly options: CodingProjectServiceOptions = {}, ) {} listProjects(): Promise { return this.store.listProjects(); } getActiveProject(): Promise { return this.store.getActiveProject(); } async requireActiveProject(): Promise { const project = await this.getActiveProject(); if (!project) { throw new CodingProjectServiceError( 409, 'CODING_ACTIVE_PROJECT_REQUIRED', 'No active coding project is selected', ); } 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.getConfig(project.id)).config; 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); if (!project) { throw new CodingProjectServiceError(404, 'CODING_PROJECT_NOT_FOUND', 'Coding project does not exist'); } return project; } async openProject(projectPath: string): Promise { const resolved = requiredAbsolutePath(projectPath, 'Project path'); const entry = await stat(resolved).catch(() => null); if (!entry?.isDirectory()) { throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path is not a directory'); } try { return await this.transitionActiveProject(async () => { const project = await this.store.openFolder(resolved); return { project, value: project }; }); } catch (error) { storageFailure(error); } } 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'); } if (input.projectType !== undefined && !isProjectType(input.projectType)) { throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project type is invalid'); } const projectPath = selectedPath ? requiredAbsolutePath(selectedPath, 'Project path') : projectChildPath(input.parentPath, input.projectName); if (!selectedPath) { const existing = await stat(projectPath).catch(() => null); if (existing) { throw new CodingProjectServiceError(409, 'CODING_PROJECT_ALREADY_EXISTS', 'Project directory already exists'); } } try { await mkdir(projectPath, { recursive: true }); } catch (error) { storageFailure(error); } try { return await this.transitionActiveProject(async () => { const { project, config } = await createLocalCodingProject({ projectPath, projectId, ...(input.projectType ? { projectType: input.projectType } : {}), }, this.store); const snapshot = { project, config, knowledgeFiles: [] }; return { project, value: snapshot }; }); } catch (error) { if (error instanceof CodingProjectServiceError) throw error; if (error instanceof Error && error.message === 'Coding project configuration already exists') { throw new CodingProjectServiceError(409, 'CODING_PROJECT_ALREADY_EXISTS', 'Coding project already exists'); } storageFailure(error); } } async removeProject(projectId: string): Promise { const project = await this.getProject(projectId); const active = await this.store.getActiveProject(); if (active?.id === project.id) { await this.options.onProjectDeactivated?.(project, 'project_removed'); } try { await this.store.removeProject(project.id); } catch (error) { storageFailure(error); } this.conversationStores.delete(project.path); } async setActiveProject(projectId: string): Promise { const project = await this.getProject(projectId); const config = await this.readCurrentConfig(project.path); if (!config) { throw new CodingProjectServiceError( 409, 'CODING_PROJECT_CONFIG_INVALID', 'Coding project configuration is unavailable', ); } try { return await this.transitionActiveProject(async () => { const activated = (await this.store.setActiveProject(project.id)) as CodingProject; return { project: activated, value: activated }; }); } catch (error) { storageFailure(error); } } async getConfig(projectId?: string): Promise { const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject(); const config = await this.requireCurrentConfig(project); return config.projectId ? await this.projectConfigSnapshot(project, config) : await this.ensureProjectIdentity(project); } async saveConfig(projectId: string, value: unknown): Promise { const current = await this.getConfig(projectId); let next: CodingProjectConfigV2; try { next = normalizeCodingProjectConfigV2(value); assertStableConfig(current.config, next); } catch (error) { if (error instanceof CodingProjectServiceError) throw error; throw new CodingProjectServiceError(400, 'CODING_PROJECT_CONFIG_INVALID', 'Coding project configuration is invalid'); } try { await (this.options.writeConfig ?? writeCodingProjectConfigV2)(current.project.path, next); } catch (error) { storageFailure(error); } await this.options.onResourcesChanged?.(current.project); return { project: current.project, config: next, knowledgeFiles: await this.listKnowledgeFiles(current.project.path), }; } async resolveProjectIdentity( localProjectId: string, identity: ProjectIdentityChoice, ): Promise { const project = await this.getProject(localProjectId); return await this.serializeIdentityTransition(project.path, async () => { const current = await this.requireCurrentConfig(project); if (current.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, 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 await this.projectConfigSnapshot(project, next); }); } 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.requireCurrentConfig(project); if (current.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.projectId, nextProjectId, ); const next = { ...current, 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 await this.projectConfigSnapshot(project, next); }); } async addKnowledgeFile(input: { projectId: string; fileName: string; contentBase64: string; }): Promise { const project = await this.getProject(input.projectId); await this.getConfig(project.id); const fileName = input.fileName.trim(); if (!fileName || fileName !== path.basename(fileName) || fileName === '.' || fileName === '..' || fileName.includes('\0')) { throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge filename is invalid'); } if (!/^[A-Za-z0-9+/]*={0,2}$/.test(input.contentBase64) || input.contentBase64.length % 4 !== 0) { throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge content is invalid'); } const content = Buffer.from(input.contentBase64, 'base64'); if (content.byteLength > MAX_KNOWLEDGE_FILE_BYTES) { throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge file exceeds 25 MB'); } const directory = path.join(project.path, 'knowledge'); try { await mkdir(directory, { recursive: true }); } catch (error) { storageFailure(error); } try { await writeFile(path.join(directory, fileName), content, { flag: 'wx' }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { throw new CodingProjectServiceError(409, 'CODING_KNOWLEDGE_ALREADY_EXISTS', 'Knowledge file already exists'); } storageFailure(error); } await this.options.onResourcesChanged?.(project); return await this.listKnowledgeFiles(project.path); } conversationStore(projectPath: string): ReturnType { const existing = this.conversationStores.get(projectPath); if (existing) return existing; const created = (this.options.createConversationStore ?? createCodingConversationStore)(projectPath); this.conversationStores.set(projectPath, created); return created; } async findActiveConversation(conversationId: string): Promise<{ project: CodingProject; conversation: CodingConversationV2; }> { const project = await this.requireActiveProject(); const conversation = await this.conversationStore(project.path).get(conversationId.trim()); if (!conversation) { throw new CodingProjectServiceError( 404, 'CODING_CONVERSATION_NOT_FOUND', 'Coding Conversation does not exist', ); } 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 ensureProjectIdentity( project: CodingProject, ): Promise { return await this.serializeIdentityTransition(project.path, async () => { const current = await this.requireCurrentConfig(project); if (current.projectId) { return await this.projectConfigSnapshot(project, current); } const nextProjectId = resolveProjectIdentity( { kind: 'create' }, this.options.createProjectId ?? randomUUID, ); await this.options.onProjectIdentityChanging?.( project, undefined, nextProjectId, ); const next = { ...current, 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 await this.projectConfigSnapshot(project, next); }); } private async requireCurrentConfig(project: CodingProject): Promise { const config = await this.readCurrentConfig(project.path); if (!config) { throw new CodingProjectServiceError( 409, 'CODING_PROJECT_CONFIG_INVALID', 'Coding project configuration is unavailable', ); } return config; } private async projectConfigSnapshot( project: CodingProject, config: CodingProjectConfigV2, ): Promise { return { project, config, knowledgeFiles: await this.listKnowledgeFiles(project.path), }; } private async listKnowledgeFiles(projectPath: string): Promise { try { const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true }); return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } } private async readCurrentConfig(projectPath: string): Promise { const current = await readCodingProjectConfigV2(projectPath); return current.status === 'valid' ? current.config : null; } private transitionActiveProject(operation: () => Promise<{ project: CodingProject; value: T; }>): Promise { const execute = async () => { const previous = await this.store.getActiveProject(); const result = await operation(); if (previous && previous.id !== result.project.id) { await this.options.onProjectDeactivated?.(previous, 'project_deactivated'); } return result.value; }; const result = this.activeTransitionTail.then(execute, execute); this.activeTransitionTail = result.then(() => undefined, () => undefined); return result; } }