390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { isProjectType, type ProjectType } from '../../shared/project-config';
|
|
import {
|
|
createCodingConversationStore,
|
|
type CodingConversationV2,
|
|
} from './conversation-store';
|
|
import {
|
|
acknowledgeLegacyConversationNotice,
|
|
normalizeCodingProjectConfigV2,
|
|
readCodingProjectConfigV2,
|
|
writeCodingProjectConfigV2,
|
|
type CodingProjectConfigV2,
|
|
} from './project-config';
|
|
import {
|
|
migrateCodingProjectToV2,
|
|
type CodingProjectMigrationDependencies,
|
|
} from './migration';
|
|
import {
|
|
createLocalCodingProject,
|
|
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;
|
|
}
|
|
|
|
export interface CodingProjectConfigSnapshot {
|
|
project: CodingProject;
|
|
config: CodingProjectConfigV2;
|
|
knowledgeFiles: string[];
|
|
}
|
|
|
|
export interface CodingProjectServiceOptions {
|
|
onResourcesChanged?(project: CodingProject): Promise<void> | void;
|
|
onProjectDeactivated?(
|
|
project: CodingProject,
|
|
reason: 'project_deactivated' | 'project_removed',
|
|
): Promise<void> | void;
|
|
createConversationStore?: typeof createCodingConversationStore;
|
|
writeConfig?: typeof writeCodingProjectConfigV2;
|
|
migration?: CodingProjectMigrationDependencies;
|
|
}
|
|
|
|
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');
|
|
}
|
|
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');
|
|
}
|
|
}
|
|
|
|
export class CodingProjectService {
|
|
private readonly conversationStores = new Map<
|
|
string,
|
|
ReturnType<typeof createCodingConversationStore>
|
|
>();
|
|
private activeTransitionTail = Promise.resolve();
|
|
private readonly migrationFlights = new Map<string, Promise<CodingProjectConfigV2>>();
|
|
|
|
constructor(
|
|
private readonly store: CodingProjectStore,
|
|
private readonly options: CodingProjectServiceOptions = {},
|
|
) {}
|
|
|
|
listProjects(): Promise<CodingProject[]> {
|
|
return this.store.listProjects();
|
|
}
|
|
|
|
getActiveProject(): Promise<CodingProject | null> {
|
|
return this.store.getActiveProject();
|
|
}
|
|
|
|
async requireActiveProject(): Promise<CodingProject> {
|
|
const project = await this.getActiveProject();
|
|
if (!project) {
|
|
throw new CodingProjectServiceError(
|
|
409,
|
|
'CODING_ACTIVE_PROJECT_REQUIRED',
|
|
'No active coding project is selected',
|
|
);
|
|
}
|
|
return project;
|
|
}
|
|
|
|
async getProject(projectId: string): Promise<CodingProject> {
|
|
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<CodingProject> {
|
|
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<CodingProjectConfigSnapshot> {
|
|
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,
|
|
...(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<void> {
|
|
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<CodingProject> {
|
|
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<CodingProjectConfigSnapshot> {
|
|
const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject();
|
|
const config = await this.readCurrentConfig(project.path);
|
|
if (!config) {
|
|
throw new CodingProjectServiceError(
|
|
409,
|
|
'CODING_PROJECT_CONFIG_INVALID',
|
|
'Coding project configuration is unavailable',
|
|
);
|
|
}
|
|
return {
|
|
project,
|
|
config,
|
|
knowledgeFiles: await this.listKnowledgeFiles(project.path),
|
|
};
|
|
}
|
|
|
|
async saveConfig(projectId: string, value: unknown): Promise<CodingProjectConfigSnapshot> {
|
|
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 addKnowledgeFile(input: {
|
|
projectId: string;
|
|
fileName: string;
|
|
contentBase64: string;
|
|
}): Promise<string[]> {
|
|
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);
|
|
}
|
|
|
|
async acknowledgeLegacyConversationNotice(projectId: string): Promise<CodingProjectConfigSnapshot> {
|
|
const project = await this.getProject(projectId);
|
|
const config = await acknowledgeLegacyConversationNotice(project.path);
|
|
return {
|
|
project,
|
|
config,
|
|
knowledgeFiles: await this.listKnowledgeFiles(project.path),
|
|
};
|
|
}
|
|
|
|
conversationStore(projectPath: string): ReturnType<typeof createCodingConversationStore> {
|
|
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 async listKnowledgeFiles(projectPath: string): Promise<string[]> {
|
|
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<CodingProjectConfigV2 | null> {
|
|
const current = await readCodingProjectConfigV2(projectPath);
|
|
if (current.status === 'valid') return current.config;
|
|
if (current.status !== 'invalid' || !this.options.migration) return null;
|
|
const existing = this.migrationFlights.get(projectPath);
|
|
if (existing) return await existing;
|
|
const flight = migrateCodingProjectToV2(projectPath, this.options.migration)
|
|
.then((result) => result.config)
|
|
.finally(() => {
|
|
if (this.migrationFlights.get(projectPath) === flight) {
|
|
this.migrationFlights.delete(projectPath);
|
|
}
|
|
});
|
|
this.migrationFlights.set(projectPath, flight);
|
|
return await flight;
|
|
}
|
|
|
|
private transitionActiveProject<T>(operation: () => Promise<{
|
|
project: CodingProject;
|
|
value: T;
|
|
}>): Promise<T> {
|
|
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;
|
|
}
|
|
}
|