fix(coding): close PI-100 review findings

This commit is contained in:
2026-08-23 20:55:49 +08:00
parent 22b4a9f9c4
commit 52b2467d5d
16 changed files with 679 additions and 122 deletions

View File

@@ -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;
}
}