feat(coding): add core host api composition
This commit is contained in:
@@ -235,6 +235,20 @@ export function createCodingConversationStore(
|
||||
});
|
||||
},
|
||||
|
||||
async delete(conversationId: string): Promise<void> {
|
||||
await mutate(async (file) => {
|
||||
const current = file.conversations.find((item) => item.id === conversationId);
|
||||
if (!current) throw new Error('Conversation does not exist');
|
||||
return {
|
||||
result: undefined,
|
||||
file: {
|
||||
schemaVersion: 2,
|
||||
conversations: file.conversations.filter((item) => item.id !== conversationId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async ensureSessionBinding(
|
||||
conversationId: string,
|
||||
createBinding: () => Promise<PiSessionBinding>,
|
||||
|
||||
290
electron/coding-projects/project-service.ts
Normal file
290
electron/coding-projects/project-service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CodingProjectServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
>();
|
||||
|
||||
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');
|
||||
}
|
||||
return await this.store.openFolder(resolved);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
try {
|
||||
const { project, config } = await createLocalCodingProject({
|
||||
projectPath,
|
||||
...(input.projectType ? { projectType: input.projectType } : {}),
|
||||
}, this.store);
|
||||
return { project, config, knowledgeFiles: [] };
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
await this.store.removeProject(project.id);
|
||||
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',
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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');
|
||||
}
|
||||
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');
|
||||
await mkdir(directory, { recursive: true });
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,16 @@ export function createCodingProjectStorageFromStore(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createElectronCodingProjectStorage(): Promise<CodingProjectStorage> {
|
||||
const Store = (await import('electron-store')).default;
|
||||
const store = new Store<{ projects?: CodingProjectStoreData }>({
|
||||
// Preserve the installed-user storage identity while replacing the
|
||||
// OpenCode-owned service and types with the product-owned store.
|
||||
name: 'opencode-projects',
|
||||
});
|
||||
return createCodingProjectStorageFromStore(store, 'projects');
|
||||
}
|
||||
|
||||
function emptyStoreData(): CodingProjectStoreData {
|
||||
return { projects: {}, activeProjectId: null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user