Files
makelore/electron/coding-projects/project-service.ts

350 lines
13 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 {
normalizeCodingProjectConfigV2,
readCodingProjectConfigV2,
writeCodingProjectConfigV2,
type CodingProjectConfigV2,
} from './project-config';
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): Promise<void> | void;
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');
}
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();
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);
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 readCodingProjectConfigV2(project.path);
if (config.status !== 'valid') {
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 result = await readCodingProjectConfigV2(project.path);
if (result.status !== 'valid') {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
return {
project,
config: result.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);
}
conversationStore(projectPath: string): ReturnType<typeof createCodingConversationStore> {
const existing = this.conversationStores.get(projectPath);
if (existing) return existing;
const created = 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 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);
}
return result.value;
};
const result = this.activeTransitionTail.then(execute, execute);
this.activeTransitionTail = result.then(() => undefined, () => undefined);
return result;
}
}