fix(coding): close PI-100 review findings
This commit is contained in:
@@ -22,7 +22,7 @@ const MAX_KNOWLEDGE_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export class CodingProjectServiceError extends Error {
|
||||
constructor(
|
||||
readonly status: 400 | 404 | 409,
|
||||
readonly status: 400 | 404 | 409 | 500,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
@@ -31,6 +31,15 @@ export class CodingProjectServiceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -47,6 +56,7 @@ export interface CodingProjectConfigSnapshot {
|
||||
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 {
|
||||
@@ -85,6 +95,7 @@ export class CodingProjectService {
|
||||
string,
|
||||
ReturnType<typeof createCodingConversationStore>
|
||||
>();
|
||||
private activeTransitionTail = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly store: CodingProjectStore,
|
||||
@@ -126,7 +137,14 @@ export class CodingProjectService {
|
||||
if (!entry?.isDirectory()) {
|
||||
throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path is not a directory');
|
||||
}
|
||||
return await this.store.openFolder(resolved);
|
||||
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> {
|
||||
@@ -146,19 +164,26 @@ export class CodingProjectService {
|
||||
throw new CodingProjectServiceError(409, 'CODING_PROJECT_ALREADY_EXISTS', 'Project directory already exists');
|
||||
}
|
||||
}
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
try {
|
||||
const { project, config } = await createLocalCodingProject({
|
||||
projectPath,
|
||||
...(input.projectType ? { projectType: input.projectType } : {}),
|
||||
}, this.store);
|
||||
return { project, config, knowledgeFiles: [] };
|
||||
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');
|
||||
}
|
||||
throw error;
|
||||
storageFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +191,11 @@ export class CodingProjectService {
|
||||
const project = await this.getProject(projectId);
|
||||
const active = await this.store.getActiveProject();
|
||||
if (active?.id === project.id) await this.options.onProjectDeactivated?.(project);
|
||||
await this.store.removeProject(project.id);
|
||||
try {
|
||||
await this.store.removeProject(project.id);
|
||||
} catch (error) {
|
||||
storageFailure(error);
|
||||
}
|
||||
this.conversationStores.delete(project.path);
|
||||
}
|
||||
|
||||
@@ -180,9 +209,14 @@ export class CodingProjectService {
|
||||
'Coding project configuration is unavailable',
|
||||
);
|
||||
}
|
||||
const active = await this.store.getActiveProject();
|
||||
if (active && active.id !== project.id) await this.options.onProjectDeactivated?.(active);
|
||||
return (await this.store.setActiveProject(project.id)) as CodingProject;
|
||||
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> {
|
||||
@@ -208,11 +242,15 @@ export class CodingProjectService {
|
||||
try {
|
||||
next = normalizeCodingProjectConfigV2(value);
|
||||
assertStableConfig(current.config, next);
|
||||
await writeCodingProjectConfigV2(current.project.path, 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,
|
||||
@@ -241,14 +279,18 @@ export class CodingProjectService {
|
||||
throw new CodingProjectServiceError(400, 'CODING_KNOWLEDGE_REQUEST_INVALID', 'Knowledge file exceeds 25 MB');
|
||||
}
|
||||
const directory = path.join(project.path, 'knowledge');
|
||||
await mkdir(directory, { recursive: true });
|
||||
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');
|
||||
}
|
||||
throw error;
|
||||
storageFailure(error);
|
||||
}
|
||||
await this.options.onResourcesChanged?.(project);
|
||||
return await this.listKnowledgeFiles(project.path);
|
||||
@@ -287,4 +329,21 @@ export class CodingProjectService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user