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

575 lines
20 KiB
TypeScript

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> | void;
onProjectIdentityChanging?(
project: CodingProject,
previousProjectId: string | undefined,
nextProjectId: string,
): Promise<void> | void;
onProjectDeactivated?(
project: CodingProject,
reason: 'project_deactivated' | 'project_removed',
): Promise<void> | 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<typeof createCodingConversationStore>
>();
private activeTransitionTail = Promise.resolve();
private readonly identityTransitionTails = new Map<string, Promise<void>>();
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 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);
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 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<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 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;
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 = (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<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 });
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);
return current.status === 'valid' ? current.config : null;
}
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;
}
}