fix(coding): close PI-100 review findings
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-core-host-api-a17f6c2e
|
||||
- Base commit: 98bac206396198cc276c659ed988372fc5c8bc10
|
||||
- Owner: codex-root
|
||||
- Status: Review
|
||||
- Status: In Progress
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -85,6 +85,18 @@
|
||||
Vite build, Windows Electron coverage, documentation gates, and planner
|
||||
review; correct confirmed failures before completion.
|
||||
|
||||
## Planner Review Correction
|
||||
|
||||
- Planner review of `98bac20..22b4a9f` concluded `NEEDS FIX` and kept the exact
|
||||
Ready Frontier at `{PI-100}`.
|
||||
- The correction scope is limited to eight confirmed contract gaps: unresolved
|
||||
model recovery, session trash on delete, protected uncertain-request
|
||||
tombstones, cleanup across every active-project transition, Renderer-safe
|
||||
project DTOs, production authentication recovery wiring, failed-fork runtime
|
||||
cleanup, and stable fixed Host error projection including storage failures.
|
||||
- PI-105 remains closed; its vendor-neutral project seam and live target
|
||||
`get_commands` seam were accepted structurally.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added the vendor-neutral `CodingProjectService` and
|
||||
|
||||
@@ -25,7 +25,12 @@ import { createPiManagedSubagentChildOpener } from '../coding-runtime/pi/subagen
|
||||
import { PiSubagentScheduler } from '../coding-runtime/pi/subagent';
|
||||
import { PiProcessBudget, PiWorkerPool } from '../coding-runtime/pi/worker-pool';
|
||||
import { getProviderService } from '../services/providers/provider-service';
|
||||
import {
|
||||
isCodingProviderAuthenticationError,
|
||||
refreshCodingProviderCredential,
|
||||
} from './coding-provider-auth';
|
||||
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
|
||||
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
|
||||
|
||||
export interface CodingCompositionPaths {
|
||||
executablePath: string;
|
||||
@@ -128,6 +133,8 @@ export function createCodingComposition(
|
||||
buildPiProviderCatalog(await loadProviderInput()),
|
||||
model,
|
||||
),
|
||||
isAuthenticationError: isCodingProviderAuthenticationError,
|
||||
refreshCredential: refreshCodingProviderCredential,
|
||||
resolveImages: async (refs) => await Promise.all(refs.map(async ({ attachmentId }) => {
|
||||
const record = await attachments.read(attachmentId);
|
||||
return {
|
||||
@@ -155,7 +162,15 @@ export function createCodingComposition(
|
||||
]);
|
||||
},
|
||||
});
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
const conversations = new CodingConversationService(projects, runtime, {
|
||||
archiveSession: async ({ projectId, sessionKey }) => {
|
||||
await archivePiConversationSession({
|
||||
userDataDir: options.paths.userDataDir,
|
||||
projectId,
|
||||
sessionKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = createCodingProductHost({
|
||||
projects,
|
||||
productTools,
|
||||
|
||||
40
electron/api/coding-provider-auth.ts
Normal file
40
electron/api/coding-provider-auth.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { resolvePiProviderCredentialFromSecretStore } from '../coding-runtime/pi/provider-config';
|
||||
import { getProviderService } from '../services/providers/provider-service';
|
||||
import {
|
||||
getFreshWorksSquareAIGatewayCredential,
|
||||
markWorksSquareAIGatewayCredentialExpired,
|
||||
} from '../services/works-square-ai-gateway';
|
||||
|
||||
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
||||
const AUTHENTICATION_ERROR_PATTERN = /\b(?:401|403|unauthori[sz]ed|forbidden|authentication failed|auth failed|invalid (?:api key|credential|access token|bearer token)|(?:access |bearer )?token expired)\b/i;
|
||||
|
||||
export function isCodingProviderAuthenticationError(error: unknown): boolean {
|
||||
return error instanceof Error && AUTHENTICATION_ERROR_PATTERN.test(error.message);
|
||||
}
|
||||
|
||||
export async function refreshCodingProviderCredential(accountId: string): Promise<void> {
|
||||
const providerService = getProviderService();
|
||||
const account = await providerService.getAccount(accountId);
|
||||
if (!account?.enabled) throw new Error('Provider account is unavailable');
|
||||
|
||||
if (account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
||||
markWorksSquareAIGatewayCredentialExpired();
|
||||
const credential = await getFreshWorksSquareAIGatewayCredential();
|
||||
if (!credential) throw new Error('Provider credential refresh failed');
|
||||
await providerService.updateAccount(account.id, {
|
||||
baseUrl: credential.oneApiBaseUrl,
|
||||
metadata: {
|
||||
...account.metadata,
|
||||
worksSquareCredentialExpiresAt: credential.expiresAt === null
|
||||
? undefined
|
||||
: new Date(credential.expiresAt).toISOString(),
|
||||
},
|
||||
}, credential.accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await resolvePiProviderCredentialFromSecretStore(account);
|
||||
if (!current && account.authMode !== 'local') {
|
||||
throw new Error('Provider credential is unavailable');
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export async function handleCodingConversationRoutes(
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_CORE_UNAVAILABLE',
|
||||
error: 'Coding services are unavailable',
|
||||
error: '本地编程服务暂时不可用。',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,33 +2,22 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { CodingProductHostError } from '../coding-product-services';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { sendFixedCodingError } from './coding-route-errors';
|
||||
|
||||
function unavailable(res: ServerResponse): void {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE',
|
||||
error: 'Coding product tools are unavailable',
|
||||
});
|
||||
sendFixedCodingError(res, 503, 'CODING_PRODUCT_TOOLS_UNAVAILABLE');
|
||||
}
|
||||
|
||||
function serviceError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof CodingProductHostError) {
|
||||
sendJson(res, error.status, {
|
||||
success: false,
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
sendFixedCodingError(res, error.status, error.code);
|
||||
return;
|
||||
}
|
||||
const code = error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: '';
|
||||
if (code === 'ENOENT') {
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'CODING_FILE_NOT_FOUND',
|
||||
error: 'Project file does not exist',
|
||||
});
|
||||
sendFixedCodingError(res, 404, 'CODING_FILE_NOT_FOUND');
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
@@ -44,18 +33,10 @@ function serviceError(res: ServerResponse, error: unknown): void {
|
||||
'Project file is not valid UTF-8 text',
|
||||
]);
|
||||
if (knownInputError.has(message)) {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
code: 'CODING_FILE_REQUEST_INVALID',
|
||||
error: message,
|
||||
});
|
||||
sendFixedCodingError(res, 400, 'CODING_FILE_REQUEST_INVALID');
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, {
|
||||
success: false,
|
||||
code: 'CODING_PRODUCT_TOOL_FAILED',
|
||||
error: 'Coding product request failed',
|
||||
});
|
||||
sendFixedCodingError(res, 500, 'CODING_PRODUCT_TOOL_FAILED');
|
||||
}
|
||||
|
||||
export async function handleCodingFileRoutes(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { ProjectType } from '../../../shared/project-config';
|
||||
import type { CodingProjectConfigSnapshot } from '../../coding-projects/project-service';
|
||||
import type { CodingProject } from '../../coding-projects/project-store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson, sendNoContent } from '../route-utils';
|
||||
import { sendCodingRouteError } from './coding-route-errors';
|
||||
@@ -9,6 +11,19 @@ function isProjectRoute(pathname: string): boolean {
|
||||
|| pathname.startsWith('/api/coding/projects/');
|
||||
}
|
||||
|
||||
function publicProject(project: CodingProject | null): Omit<CodingProject, 'path'> | null {
|
||||
if (!project) return null;
|
||||
const { path: _path, ...safe } = project;
|
||||
return safe;
|
||||
}
|
||||
|
||||
function publicProjectSnapshot(snapshot: CodingProjectConfigSnapshot) {
|
||||
return {
|
||||
...snapshot,
|
||||
project: publicProject(snapshot.project),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCodingProjectRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -22,7 +37,7 @@ export async function handleCodingProjectRoutes(
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_CORE_UNAVAILABLE',
|
||||
error: 'Coding services are unavailable',
|
||||
error: '本地编程服务暂时不可用。',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -33,12 +48,15 @@ export async function handleCodingProjectRoutes(
|
||||
projects.listProjects(),
|
||||
projects.getActiveProject(),
|
||||
]);
|
||||
sendJson(res, 200, { projects: items, activeProjectId: activeProject?.id ?? null });
|
||||
sendJson(res, 200, {
|
||||
projects: items.map((project) => publicProject(project)),
|
||||
activeProjectId: activeProject?.id ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/open' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ projectPath?: string }>(req);
|
||||
sendJson(res, 200, { project: await projects.openProject(body.projectPath ?? '') });
|
||||
sendJson(res, 200, { project: publicProject(await projects.openProject(body.projectPath ?? '')) });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/create' && req.method === 'POST') {
|
||||
@@ -48,7 +66,7 @@ export async function handleCodingProjectRoutes(
|
||||
projectName?: string;
|
||||
projectType?: ProjectType;
|
||||
}>(req);
|
||||
sendJson(res, 201, { snapshot: await projects.createProject(body) });
|
||||
sendJson(res, 201, { snapshot: publicProjectSnapshot(await projects.createProject(body)) });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') {
|
||||
@@ -58,24 +76,26 @@ export async function handleCodingProjectRoutes(
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/active' && req.method === 'GET') {
|
||||
sendJson(res, 200, { project: await projects.getActiveProject() });
|
||||
sendJson(res, 200, { project: publicProject(await projects.getActiveProject()) });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/active' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ projectId?: string }>(req);
|
||||
sendJson(res, 200, { project: await projects.setActiveProject(body.projectId ?? '') });
|
||||
sendJson(res, 200, { project: publicProject(await projects.setActiveProject(body.projectId ?? '')) });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/config' && req.method === 'GET') {
|
||||
sendJson(res, 200, {
|
||||
snapshot: await projects.getConfig(url.searchParams.get('projectId')?.trim() || undefined),
|
||||
snapshot: publicProjectSnapshot(
|
||||
await projects.getConfig(url.searchParams.get('projectId')?.trim() || undefined),
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/config' && req.method === 'PUT') {
|
||||
const body = await parseJsonBody<{ projectId?: string; config?: unknown }>(req);
|
||||
sendJson(res, 200, {
|
||||
snapshot: await projects.saveConfig(body.projectId ?? '', body.config),
|
||||
snapshot: publicProjectSnapshot(await projects.saveConfig(body.projectId ?? '', body.config)),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,27 +3,72 @@ import { CodingProjectServiceError } from '../../coding-projects/project-service
|
||||
import { CodingConversationServiceError } from '../../coding-runtime/conversation-service';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
const FIXED_CODING_ERROR_MESSAGES: Readonly<Record<string, string>> = {
|
||||
CODING_ACTIVE_PROJECT_REQUIRED: '请先选择一个编程项目。',
|
||||
CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。',
|
||||
CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。',
|
||||
CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。',
|
||||
CODING_CONVERSATION_REQUEST_INVALID: '对话请求无效,请检查输入。',
|
||||
CODING_FILE_NOT_FOUND: '指定的项目文件不存在。',
|
||||
CODING_FILE_REQUEST_INVALID: '文件请求无效,请检查输入。',
|
||||
CODING_INTERACTION_REQUEST_INVALID: '交互响应无效,请重试。',
|
||||
CODING_KNOWLEDGE_ALREADY_EXISTS: '同名知识文件已存在。',
|
||||
CODING_KNOWLEDGE_REQUEST_INVALID: '知识文件无效,请检查后重试。',
|
||||
CODING_MIGRATION_MODEL_REQUIRED: '请先为该对话选择一个可用模型。',
|
||||
CODING_MODEL_UNAVAILABLE: '所选模型当前不可用,请重新选择。',
|
||||
CODING_PROJECT_ALREADY_EXISTS: '该编程项目已经存在。',
|
||||
CODING_PROJECT_CONFIG_INVALID: '项目配置无效,请检查后重试。',
|
||||
CODING_PROJECT_IDENTITY_IMMUTABLE: '项目创建标识不能修改。',
|
||||
CODING_PROJECT_NOT_FOUND: '指定的编程项目不存在。',
|
||||
CODING_PROJECT_REQUEST_INVALID: '项目请求无效,请检查输入。',
|
||||
CODING_PROJECT_TYPE_IMMUTABLE: '项目类型创建后不能修改。',
|
||||
CODING_PRODUCT_TOOL_FAILED: '项目工具执行失败,请重试。',
|
||||
CODING_PRODUCT_TOOLS_UNAVAILABLE: '项目工具暂时不可用。',
|
||||
CODING_PROVIDER_AUTH_REQUIRED: 'Provider 凭证不可用,请修复账号后重试。',
|
||||
CODING_REQUEST_CAPACITY_EXCEEDED: '本地请求队列已满,请稍后重试。',
|
||||
CODING_REQUEST_ID_CONFLICT: '该请求标识已用于不同内容,请使用新的标识。',
|
||||
CODING_REQUEST_UNCERTAIN: '请求状态无法确认,请先核对对话后再决定是否重试。',
|
||||
CODING_RUNTIME_PROTOCOL_ERROR: '本地 Agent 通信异常,请执行恢复。',
|
||||
CODING_RUNTIME_READY_TIMEOUT: '本地 Agent 启动超时,请执行恢复。',
|
||||
CODING_RUNTIME_START_FAILED: '本地 Agent 启动失败,请执行恢复。',
|
||||
CODING_RUNTIME_UNAVAILABLE: '本地编程运行时暂时不可用。',
|
||||
CODING_SESSION_UNREADABLE: '对话会话无法读取,原文件已保留。',
|
||||
CODING_STORAGE_WRITE_FAILED: '本地数据写入失败,请检查存储后重试。',
|
||||
};
|
||||
|
||||
function fixedMessage(code: string): string {
|
||||
return FIXED_CODING_ERROR_MESSAGES[code] ?? '编程操作失败,请重试。';
|
||||
}
|
||||
|
||||
export function sendFixedCodingError(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
code: string,
|
||||
): void {
|
||||
sendJson(res, status, {
|
||||
success: false,
|
||||
code,
|
||||
error: fixedMessage(code),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendCodingRouteError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof CodingProjectServiceError || error instanceof CodingConversationServiceError) {
|
||||
sendJson(res, error.status, {
|
||||
success: false,
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
sendFixedCodingError(res, error.status, error.code);
|
||||
return;
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
code: 'CODING_REQUEST_INVALID',
|
||||
error: 'Request JSON is invalid',
|
||||
error: '请求内容无效,请检查后重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, {
|
||||
success: false,
|
||||
code: 'CODING_REQUEST_FAILED',
|
||||
error: 'Coding request failed',
|
||||
error: '编程操作失败,请重试。',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +415,7 @@ export interface CodingConversationRuntime {
|
||||
steer(input: QueueMessageInput): Promise<QueueAcceptance>;
|
||||
followUp(input: QueueMessageInput): Promise<QueueAcceptance>;
|
||||
abort(conversationId: string): Promise<void>;
|
||||
validateModel(model: ProductModelRef): Promise<ProductModelRef>;
|
||||
setModel(input: SetConversationModelInput): Promise<ConversationModelState>;
|
||||
setThinking(input: SetThinkingLevelInput): Promise<ConversationModelState>;
|
||||
compact(conversationId: string): Promise<void>;
|
||||
|
||||
@@ -27,7 +27,7 @@ const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
|
||||
|
||||
export class CodingConversationServiceError extends Error {
|
||||
constructor(
|
||||
readonly status: 400 | 404 | 409 | 503,
|
||||
readonly status: 400 | 404 | 409 | 500 | 503,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
@@ -41,6 +41,14 @@ export type CodingPromptAcceptance = PromptAcceptance;
|
||||
interface AcceptanceRecord {
|
||||
fingerprint: string;
|
||||
flight: Promise<CodingPromptAcceptance>;
|
||||
state: { value: 'protected' | 'settled' };
|
||||
}
|
||||
|
||||
export interface CodingConversationServiceOptions {
|
||||
archiveSession?(input: {
|
||||
projectId: string;
|
||||
sessionKey: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export type CodingConversationStreamEvent =
|
||||
@@ -78,8 +86,17 @@ function runtimeError(error: unknown): never {
|
||||
? error.publicError as { code?: unknown; message?: unknown }
|
||||
: null;
|
||||
if (publicError && typeof publicError.code === 'string') {
|
||||
const status = publicError.code === 'CODING_CONVERSATION_NOT_FOUND'
|
||||
? 404
|
||||
: publicError.code === 'CODING_STORAGE_WRITE_FAILED'
|
||||
? 500
|
||||
: publicError.code === 'CODING_RUNTIME_START_FAILED'
|
||||
|| publicError.code === 'CODING_RUNTIME_READY_TIMEOUT'
|
||||
|| publicError.code === 'CODING_RUNTIME_PROTOCOL_ERROR'
|
||||
? 503
|
||||
: 409;
|
||||
throw new CodingConversationServiceError(
|
||||
publicError.code === 'CODING_CONVERSATION_NOT_FOUND' ? 404 : 409,
|
||||
status,
|
||||
publicError.code,
|
||||
typeof publicError.message === 'string' ? publicError.message : 'Coding runtime request failed',
|
||||
);
|
||||
@@ -87,6 +104,21 @@ function runtimeError(error: unknown): never {
|
||||
throw new CodingConversationServiceError(503, 'CODING_RUNTIME_UNAVAILABLE', 'The local coding runtime is unavailable');
|
||||
}
|
||||
|
||||
async function persist<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof CodingConversationServiceError || error instanceof CodingProjectServiceError) {
|
||||
throw error;
|
||||
}
|
||||
throw new CodingConversationServiceError(
|
||||
500,
|
||||
'CODING_STORAGE_WRITE_FAILED',
|
||||
'Coding data could not be persisted',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function publicConversation(conversation: CodingConversationV2): CodingConversationV2 {
|
||||
const { piSessionId: _piSessionId, sessionKey: _sessionKey, ...safe } = conversation;
|
||||
return safe;
|
||||
@@ -135,6 +167,7 @@ export class CodingConversationService {
|
||||
constructor(
|
||||
readonly projects: CodingProjectService,
|
||||
readonly runtime: CodingConversationRuntime,
|
||||
private readonly options: CodingConversationServiceOptions = {},
|
||||
) {}
|
||||
|
||||
async listConversations(projectId?: string): Promise<CodingConversationV2[]> {
|
||||
@@ -160,12 +193,12 @@ export class CodingConversationService {
|
||||
if (!agent) {
|
||||
throw new CodingConversationServiceError(404, 'CODING_AGENT_NOT_FOUND', 'Coding project Agent does not exist');
|
||||
}
|
||||
const created = await this.projects.conversationStore(project.path).create({
|
||||
const created = await persist(() => this.projects.conversationStore(project.path).create({
|
||||
agentId,
|
||||
title: requiredString(input.title, 'Conversation title', MAX_TITLE_LENGTH),
|
||||
model: agent.model,
|
||||
modelResolution: agent.modelResolution,
|
||||
});
|
||||
}));
|
||||
return publicConversation(created);
|
||||
}
|
||||
|
||||
@@ -189,7 +222,7 @@ export class CodingConversationService {
|
||||
if (patch.unread !== undefined && typeof patch.unread !== 'boolean') {
|
||||
throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Conversation unread state is invalid');
|
||||
}
|
||||
const updated = await this.projects.conversationStore(project.path).patchMetadata(conversationId, {
|
||||
const updated = await persist(() => this.projects.conversationStore(project.path).patchMetadata(conversationId, {
|
||||
...(patch.title !== undefined
|
||||
? { title: requiredString(patch.title, 'Conversation title', MAX_TITLE_LENGTH) }
|
||||
: {}),
|
||||
@@ -197,18 +230,19 @@ export class CodingConversationService {
|
||||
? { archivedAt: patch.archived ? new Date().toISOString() : null }
|
||||
: {}),
|
||||
...(typeof patch.unread === 'boolean' ? { unread: patch.unread } : {}),
|
||||
});
|
||||
}));
|
||||
return publicConversation(updated);
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
||||
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
try {
|
||||
await this.runtime.dispose(conversationId);
|
||||
} catch (error) {
|
||||
runtimeError(error);
|
||||
}
|
||||
await this.projects.conversationStore(project.path).delete(conversationId);
|
||||
await this.archiveSession(project.id, conversation.sessionKey);
|
||||
await persist(() => this.projects.conversationStore(project.path).delete(conversationId));
|
||||
this.prepareFlights.delete(conversationId);
|
||||
for (const key of [...this.acceptances.keys()]) {
|
||||
if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key);
|
||||
@@ -263,18 +297,26 @@ export class CodingConversationService {
|
||||
if (prior.fingerprint !== fingerprint) {
|
||||
throw new CodingConversationServiceError(409, 'CODING_REQUEST_ID_CONFLICT', 'Client request id was already used');
|
||||
}
|
||||
if (prior.state.value === 'settled') {
|
||||
this.acceptances.delete(key);
|
||||
this.acceptances.set(key, prior);
|
||||
}
|
||||
return await prior.flight;
|
||||
}
|
||||
this.reserveAcceptanceSlot();
|
||||
const acceptanceState: AcceptanceRecord['state'] = { value: 'protected' };
|
||||
const flight = (async (): Promise<CodingPromptAcceptance> => {
|
||||
try {
|
||||
await this.ensurePrepared(conversationId);
|
||||
return await this.runtime.prompt({
|
||||
const acceptance = await this.runtime.prompt({
|
||||
conversationId,
|
||||
clientRequestId,
|
||||
mode,
|
||||
text: input.text as string,
|
||||
attachments: normalizedAttachments,
|
||||
});
|
||||
acceptanceState.value = 'settled';
|
||||
return acceptance;
|
||||
} catch (error) {
|
||||
const publicCode = error && typeof error === 'object'
|
||||
&& 'publicError' in error
|
||||
@@ -287,8 +329,7 @@ export class CodingConversationService {
|
||||
runtimeError(error);
|
||||
}
|
||||
})();
|
||||
this.acceptances.set(key, { fingerprint, flight });
|
||||
this.trimAcceptances();
|
||||
this.acceptances.set(key, { fingerprint, flight, state: acceptanceState });
|
||||
return await flight;
|
||||
}
|
||||
|
||||
@@ -298,20 +339,23 @@ export class CodingConversationService {
|
||||
}
|
||||
|
||||
async setModel(conversationId: string, model: ProductModelRef): Promise<ConversationModelState> {
|
||||
await this.ensurePrepared(conversationId);
|
||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
||||
let selected: ProductModelRef;
|
||||
try {
|
||||
const state = await this.runtime.setModel({
|
||||
conversationId,
|
||||
accountId: model.accountId,
|
||||
modelId: model.modelId,
|
||||
});
|
||||
const withThinking = state.model
|
||||
? { model: { ...state.model, thinkingLevel: model.thinkingLevel }, modelResolution: 'resolved' as const }
|
||||
: state;
|
||||
if (withThinking.model) await this.runtime.setThinking({ conversationId, thinkingLevel: model.thinkingLevel });
|
||||
await this.projects.conversationStore(project.path).setModelState(conversationId, withThinking);
|
||||
return withThinking;
|
||||
selected = await this.runtime.validateModel(model);
|
||||
} catch (error) { runtimeError(error); }
|
||||
const state: ConversationModelState = {
|
||||
model: selected,
|
||||
modelResolution: 'resolved',
|
||||
};
|
||||
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
|
||||
const preparing = this.prepareFlights.get(conversationId);
|
||||
if (preparing) await preparing.catch(() => undefined);
|
||||
try {
|
||||
await this.runtime.dispose(conversationId);
|
||||
this.prepareFlights.delete(conversationId);
|
||||
await this.ensurePrepared(conversationId);
|
||||
return state;
|
||||
} catch (error) { runtimeError(error); }
|
||||
}
|
||||
|
||||
@@ -338,12 +382,13 @@ export class CodingConversationService {
|
||||
async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> {
|
||||
const prepared = await this.ensurePrepared(sourceConversationId);
|
||||
const source = await this.projects.findActiveConversation(sourceConversationId);
|
||||
const created = await this.projects.conversationStore(source.project.path).create({
|
||||
const store = this.projects.conversationStore(source.project.path);
|
||||
const created = await persist(() => store.create({
|
||||
agentId: source.conversation.agentId,
|
||||
title: `${source.conversation.title} (fork)`,
|
||||
model: source.conversation.model,
|
||||
modelResolution: source.conversation.modelResolution,
|
||||
});
|
||||
}));
|
||||
try {
|
||||
await this.runtime.fork({
|
||||
sourceConversationId,
|
||||
@@ -360,7 +405,15 @@ export class CodingConversationService {
|
||||
});
|
||||
return publicConversation(created);
|
||||
} catch (error) {
|
||||
await this.projects.conversationStore(source.project.path).delete(created.id).catch(() => undefined);
|
||||
try {
|
||||
await this.runtime.dispose(created.id);
|
||||
const latest = await store.get(created.id);
|
||||
await this.archiveSession(source.project.id, latest?.sessionKey);
|
||||
await persist(() => store.delete(created.id));
|
||||
} catch {
|
||||
// Keep product metadata when cleanup cannot complete so the partial
|
||||
// runtime/session remains owned and can be diagnosed or deleted later.
|
||||
}
|
||||
runtimeError(error);
|
||||
}
|
||||
}
|
||||
@@ -493,11 +546,30 @@ export class CodingConversationService {
|
||||
return flight;
|
||||
}
|
||||
|
||||
private trimAcceptances(): void {
|
||||
while (this.acceptances.size > MAX_ACCEPTANCES) {
|
||||
const key = this.acceptances.keys().next().value as string | undefined;
|
||||
if (!key) return;
|
||||
this.acceptances.delete(key);
|
||||
private reserveAcceptanceSlot(): void {
|
||||
while (this.acceptances.size >= MAX_ACCEPTANCES) {
|
||||
const settled = [...this.acceptances.entries()]
|
||||
.find(([, record]) => record.state.value === 'settled');
|
||||
if (!settled) {
|
||||
throw new CodingConversationServiceError(
|
||||
503,
|
||||
'CODING_REQUEST_CAPACITY_EXCEEDED',
|
||||
'The local request registry is full',
|
||||
);
|
||||
}
|
||||
this.acceptances.delete(settled[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private async archiveSession(projectId: string, sessionKey: string | undefined): Promise<void> {
|
||||
if (!sessionKey) return;
|
||||
if (!this.options.archiveSession) {
|
||||
throw new CodingConversationServiceError(
|
||||
500,
|
||||
'CODING_STORAGE_WRITE_FAILED',
|
||||
'Coding session archival is unavailable',
|
||||
);
|
||||
}
|
||||
await persist(() => this.options.archiveSession!({ projectId, sessionKey }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ForkConversationInput,
|
||||
ForkResult,
|
||||
PrepareConversationInput,
|
||||
ProductModelRef,
|
||||
PromptAcceptance,
|
||||
PromptConversationInput,
|
||||
QueueAcceptance,
|
||||
@@ -316,6 +317,17 @@ export class InMemoryConversationRuntime implements CodingConversationRuntime {
|
||||
}, current.runId);
|
||||
}
|
||||
|
||||
async validateModel(model: ProductModelRef): Promise<ProductModelRef> {
|
||||
if (!model.accountId.trim() || !model.modelId.trim()) {
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_MODEL_UNAVAILABLE',
|
||||
'The selected model is unavailable',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return clone(model);
|
||||
}
|
||||
|
||||
async setModel(input: SetConversationModelInput): Promise<ConversationModelState> {
|
||||
const snapshot = this.snapshot(input.conversationId);
|
||||
const thinkingLevel = snapshot.conversation.model.model?.thinkingLevel ?? 'off';
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import { mkdir, rename, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BUNDLED_CODING_SKILL_IDS,
|
||||
type BundledCodingSkillId,
|
||||
} from '../../../shared/coding-skills';
|
||||
import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json';
|
||||
import { validateSessionKey } from '../../coding-projects/conversation-store';
|
||||
import type { PiProviderSelection } from './provider-config';
|
||||
import type { PiManagedInputRevision } from './managed-input-revision';
|
||||
|
||||
@@ -104,6 +105,31 @@ export async function ensurePiManagedPaths(userDataDir: string): Promise<PiManag
|
||||
return paths;
|
||||
}
|
||||
|
||||
export async function archivePiConversationSession(input: {
|
||||
userDataDir: string;
|
||||
projectId: string;
|
||||
sessionKey: string;
|
||||
}): Promise<string | null> {
|
||||
const projectId = managedSegment(input.projectId, 'Project id');
|
||||
const sessionKey = validateSessionKey(input.sessionKey);
|
||||
const paths = getPiManagedPaths(input.userDataDir);
|
||||
const source = path.join(paths.sessionsDir, projectId, `${sessionKey}.jsonl`);
|
||||
const targetDirectory = path.join(paths.trashDir, projectId);
|
||||
const target = path.join(targetDirectory, `${sessionKey}.jsonl`);
|
||||
await mkdir(targetDirectory, { recursive: true });
|
||||
try {
|
||||
await rename(source, target);
|
||||
return target;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
const alreadyArchived = await stat(target).catch((targetError: NodeJS.ErrnoException) => {
|
||||
if (targetError.code === 'ENOENT') return null;
|
||||
throw targetError;
|
||||
});
|
||||
return alreadyArchived?.isFile() ? target : null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkillIds(skillIds: readonly string[]): BundledCodingSkillId[] {
|
||||
const result: BundledCodingSkillId[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -672,6 +672,15 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async validateModel(model: ProductModelRef): Promise<ProductModelRef> {
|
||||
const selection = await this.resolveModel(model);
|
||||
return {
|
||||
accountId: selection.accountId,
|
||||
modelId: selection.modelId,
|
||||
thinkingLevel: model.thinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
async setModel(input: SetConversationModelInput): Promise<ConversationModelState> {
|
||||
await this.waitForProjection(input.conversationId);
|
||||
const snapshot = this.snapshot(input.conversationId);
|
||||
@@ -957,19 +966,30 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
||||
if (!this.isAuthenticationError || !this.refreshCredential || !input.model.model) {
|
||||
return await this.pool.prepare(input);
|
||||
}
|
||||
return await this.providerRefresh.withSingleAuthRecovery({
|
||||
accountId: input.model.model.accountId,
|
||||
operation: async () => await this.pool.prepare(input),
|
||||
isAuthenticationError: this.isAuthenticationError,
|
||||
refreshCredential: async () => await this.refreshCredential!(input.model.model!.accountId),
|
||||
reopenWorker: async () => {
|
||||
if (!this.pool.getState(input.conversationId)) return;
|
||||
const recovered = await this.pool.recover(input.conversationId);
|
||||
if (this.states.has(input.conversationId)) {
|
||||
await this.requestHydration(input.conversationId, recovered, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await this.providerRefresh.withSingleAuthRecovery({
|
||||
accountId: input.model.model.accountId,
|
||||
operation: async () => await this.pool.prepare(input),
|
||||
isAuthenticationError: this.isAuthenticationError,
|
||||
refreshCredential: async () => await this.refreshCredential!(input.model.model!.accountId),
|
||||
reopenWorker: async () => {
|
||||
if (!this.pool.getState(input.conversationId)) return;
|
||||
const recovered = await this.pool.recover(input.conversationId);
|
||||
if (this.states.has(input.conversationId)) {
|
||||
await this.requestHydration(input.conversationId, recovered, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isAuthenticationError(error)) {
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
'Provider authentication failed after one recovery attempt',
|
||||
true,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async acceptPrompt(
|
||||
@@ -1008,13 +1028,20 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
||||
}, runId);
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = this.isAuthenticationError?.(error)
|
||||
? new CodingRuntimeContractError(
|
||||
'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
'Provider authentication failed after one recovery attempt',
|
||||
true,
|
||||
)
|
||||
: error;
|
||||
this.pool.failTopLevel(
|
||||
conversationId,
|
||||
runId,
|
||||
error instanceof Error ? error : new Error('Prompt acceptance failed'),
|
||||
failure instanceof Error ? failure : new Error('Prompt acceptance failed'),
|
||||
);
|
||||
this.failRun(conversationId, runId, error);
|
||||
throw error;
|
||||
this.failRun(conversationId, runId, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { HostApiContext } from '../../electron/api/context';
|
||||
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
|
||||
import { createCodingComposition } from '../../electron/api/coding-composition';
|
||||
import { isCodingProviderAuthenticationError } from '../../electron/api/coding-provider-auth';
|
||||
import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import type { PromptConversationInput } from '../../electron/coding-runtime/contracts';
|
||||
import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader';
|
||||
|
||||
const roots: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
@@ -48,8 +50,9 @@ async function setup(runtime = new InMemoryConversationRuntime({
|
||||
})) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-core-'));
|
||||
roots.push(root);
|
||||
let projectSequence = 0;
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-a',
|
||||
createId: () => projectSequence++ === 0 ? 'project-a' : `project-extra-${projectSequence}`,
|
||||
now: () => '2026-08-23T00:00:00.000Z',
|
||||
});
|
||||
await createLocalCodingProject({
|
||||
@@ -69,7 +72,7 @@ async function setup(runtime = new InMemoryConversationRuntime({
|
||||
}, { now: '2026-08-23T00:00:00.000Z' });
|
||||
const projects = new CodingProjectService(store);
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
return { root, projects, conversations, runtime };
|
||||
return { root, store, projects, conversations, runtime };
|
||||
}
|
||||
|
||||
function context(setupResult: Awaited<ReturnType<typeof setup>>): HostApiContext {
|
||||
@@ -146,6 +149,100 @@ describe('PI-100 coding core Host contract', () => {
|
||||
expect(result.conversations.getDiagnostics().workers).toEqual([]);
|
||||
});
|
||||
|
||||
it('selects an unresolved model before preparing the Conversation', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-unresolved-'));
|
||||
roots.push(root);
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-unresolved',
|
||||
});
|
||||
await createLocalCodingProject({ projectPath: root }, store);
|
||||
await createCodingProjectAgent(root, {
|
||||
id: 'builder',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '实现者',
|
||||
name: 'Builder',
|
||||
model: null,
|
||||
modelResolution: 'required',
|
||||
responsibility: {
|
||||
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
});
|
||||
const runtime = new InMemoryConversationRuntime();
|
||||
const projects = new CodingProjectService(store);
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' });
|
||||
const prepare = vi.spyOn(runtime, 'prepare');
|
||||
|
||||
await expect(conversations.setModel(conversation.id, MODEL)).resolves.toEqual({
|
||||
model: MODEL,
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
expect(prepare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
conversationId: conversation.id,
|
||||
model: { model: MODEL, modelResolution: 'resolved' },
|
||||
}));
|
||||
await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({
|
||||
model: MODEL,
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
const sessionKey = 'session-delete';
|
||||
await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({
|
||||
piSessionId: 'pi-session-delete',
|
||||
sessionKey,
|
||||
}));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-trash-'));
|
||||
roots.push(userDataDir);
|
||||
const sourceDirectory = path.join(
|
||||
userDataDir,
|
||||
'coding-runtime',
|
||||
'pi',
|
||||
'sessions',
|
||||
'project-a',
|
||||
);
|
||||
await mkdir(sourceDirectory, { recursive: true });
|
||||
await writeFile(path.join(sourceDirectory, `${sessionKey}.jsonl`), 'session-data');
|
||||
const conversations = new CodingConversationService(result.projects, result.runtime, {
|
||||
archiveSession: async (input) => {
|
||||
await archivePiConversationSession({ userDataDir, ...input });
|
||||
},
|
||||
});
|
||||
|
||||
await conversations.deleteConversation(conversation.id);
|
||||
|
||||
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull();
|
||||
await expect(readFile(path.join(
|
||||
userDataDir,
|
||||
'coding-runtime',
|
||||
'pi',
|
||||
'trash',
|
||||
'project-a',
|
||||
`${sessionKey}.jsonl`,
|
||||
), 'utf8')).resolves.toBe('session-data');
|
||||
});
|
||||
|
||||
it('preserves Conversation metadata when session archival fails', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({
|
||||
piSessionId: 'pi-session-preserved',
|
||||
sessionKey: 'session-preserved',
|
||||
}));
|
||||
const conversations = new CodingConversationService(result.projects, result.runtime, {
|
||||
archiveSession: async () => { throw new Error(`disk path=${result.root}`); },
|
||||
});
|
||||
|
||||
await expect(conversations.deleteConversation(conversation.id)).rejects.toMatchObject({
|
||||
status: 500,
|
||||
code: 'CODING_STORAGE_WRITE_FAILED',
|
||||
});
|
||||
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 202 acceptance, deduplicates requests, and exposes only safe diagnostics', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
@@ -338,6 +435,95 @@ describe('PI-100 coding core Host contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans the prior project for open, create, and explicit activation transitions', async () => {
|
||||
const projectRoots = await Promise.all(['a', 'b', 'c'].map(async (name) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `makelore-pi-transition-${name}-`));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}));
|
||||
let nextId = 0;
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => `project-${++nextId}`,
|
||||
});
|
||||
const first = await createLocalCodingProject({ projectPath: projectRoots[0]! }, store);
|
||||
const deactivated: string[] = [];
|
||||
const projects = new CodingProjectService(store, {
|
||||
onProjectDeactivated: async (project) => { deactivated.push(project.id); },
|
||||
});
|
||||
|
||||
const opened = await projects.openProject(projectRoots[1]!);
|
||||
const created = await projects.createProject({ projectPath: projectRoots[2]! });
|
||||
await projects.setActiveProject(first.project.id);
|
||||
|
||||
expect(deactivated).toEqual([first.project.id, opened.id, created.project.id]);
|
||||
});
|
||||
|
||||
it('removes absolute project roots from every core project response', async () => {
|
||||
const result = await setup();
|
||||
const routeContext = context(result);
|
||||
const responses = [
|
||||
await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects' }),
|
||||
await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects/active' }),
|
||||
await dispatchHostApiRequest(routeContext, {
|
||||
path: `/api/coding/projects/config?projectId=project-a`,
|
||||
}),
|
||||
await dispatchHostApiRequest(routeContext, {
|
||||
path: '/api/coding/projects/open',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath: result.root }),
|
||||
}),
|
||||
];
|
||||
const createRoot = await mkdtemp(path.join(tmpdir(), 'makelore-pi-safe-project-'));
|
||||
roots.push(createRoot);
|
||||
responses.push(await dispatchHostApiRequest(routeContext, {
|
||||
path: '/api/coding/projects/create',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath: createRoot }),
|
||||
}));
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBeLessThan(300);
|
||||
expect(JSON.stringify(response.json)).not.toContain(result.root);
|
||||
expect(JSON.stringify(response.json)).not.toContain(createRoot);
|
||||
expect(JSON.stringify(response.json)).not.toContain('"path"');
|
||||
}
|
||||
});
|
||||
|
||||
it('maps persistence failures to a stable fixed Host error', async () => {
|
||||
const result = await setup();
|
||||
const failingProjects = new CodingProjectService(result.store, {
|
||||
writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); },
|
||||
});
|
||||
const failingConversations = new CodingConversationService(failingProjects, result.runtime);
|
||||
const current = await failingProjects.getConfig('project-a');
|
||||
const response = await dispatchHostApiRequest(context({
|
||||
...result,
|
||||
projects: failingProjects,
|
||||
conversations: failingConversations,
|
||||
}), {
|
||||
path: '/api/coding/projects/config',
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: 'project-a', config: current.config }),
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 500,
|
||||
json: {
|
||||
code: 'CODING_STORAGE_WRITE_FAILED',
|
||||
error: '本地数据写入失败,请检查存储后重试。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(result.root);
|
||||
});
|
||||
|
||||
it('classifies production Provider authentication failures without exposing details', () => {
|
||||
expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: HTTP 401'))).toBe(true);
|
||||
expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: rate limited'))).toBe(false);
|
||||
});
|
||||
|
||||
it('redacts unknown runtime failures from Host responses', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
@@ -350,7 +536,7 @@ describe('PI-100 coding core Host contract', () => {
|
||||
status: 503,
|
||||
json: {
|
||||
code: 'CODING_RUNTIME_UNAVAILABLE',
|
||||
error: 'The local coding runtime is unavailable',
|
||||
error: '本地编程运行时暂时不可用。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain('secret');
|
||||
@@ -359,15 +545,24 @@ describe('PI-100 coding core Host contract', () => {
|
||||
|
||||
it('retains uncertain acceptance and never resends the same request id', async () => {
|
||||
class UncertainRuntime extends InMemoryConversationRuntime {
|
||||
calls = 0;
|
||||
uncertainCalls = 0;
|
||||
|
||||
override async prompt(_input: PromptConversationInput): Promise<never> {
|
||||
this.calls += 1;
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_REQUEST_UNCERTAIN',
|
||||
'The local Agent did not confirm the request',
|
||||
true,
|
||||
);
|
||||
override async prompt(input: PromptConversationInput) {
|
||||
if (input.clientRequestId === 'request-uncertain') {
|
||||
this.uncertainCalls += 1;
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_REQUEST_UNCERTAIN',
|
||||
'The local Agent did not confirm the request',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return {
|
||||
accepted: true as const,
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
runId: `run-${input.clientRequestId}`,
|
||||
mode: input.mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
const runtime = new UncertainRuntime();
|
||||
@@ -383,9 +578,58 @@ describe('PI-100 coding core Host contract', () => {
|
||||
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
|
||||
code: 'CODING_REQUEST_UNCERTAIN',
|
||||
});
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
await result.conversations.acceptPrompt({
|
||||
conversationId: conversation.id,
|
||||
clientRequestId: `request-settled-${index}`,
|
||||
mode: 'prompt',
|
||||
text: 'Settled',
|
||||
attachments: [],
|
||||
});
|
||||
}
|
||||
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
|
||||
code: 'CODING_REQUEST_UNCERTAIN',
|
||||
});
|
||||
expect(runtime.calls).toBe(1);
|
||||
expect(runtime.uncertainCalls).toBe(1);
|
||||
}, 15_000);
|
||||
|
||||
it('disposes and archives a partially created fork before metadata rollback', async () => {
|
||||
let bindFork: ((conversationId: string) => Promise<void>) | undefined;
|
||||
let forkTargetId = '';
|
||||
class FailingForkRuntime extends InMemoryConversationRuntime {
|
||||
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]): Promise<never> {
|
||||
forkTargetId = input.conversation.conversationId;
|
||||
await bindFork?.(forkTargetId);
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_SESSION_UNREADABLE',
|
||||
'Fork hydration failed',
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const runtime = new FailingForkRuntime();
|
||||
const result = await setup(runtime);
|
||||
const store = result.projects.conversationStore(result.root);
|
||||
bindFork = async (conversationId) => {
|
||||
await store.ensureSessionBinding(conversationId, async () => ({
|
||||
piSessionId: 'fork-session',
|
||||
sessionKey: 'fork-session-key',
|
||||
}));
|
||||
};
|
||||
const archiveSession = vi.fn(async () => undefined);
|
||||
const conversations = new CodingConversationService(result.projects, runtime, { archiveSession });
|
||||
const source = await createConversation(conversations);
|
||||
await conversations.getSnapshot(source.id);
|
||||
const dispose = vi.spyOn(runtime, 'dispose');
|
||||
|
||||
await expect(conversations.fork(source.id)).rejects.toMatchObject({
|
||||
code: 'CODING_SESSION_UNREADABLE',
|
||||
});
|
||||
expect(dispose).toHaveBeenCalledWith(forkTargetId);
|
||||
expect(archiveSession).toHaveBeenCalledWith({
|
||||
projectId: 'project-a',
|
||||
sessionKey: 'fork-session-key',
|
||||
});
|
||||
await expect(store.get(forkTargetId)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('PI-105 coding product routes', () => {
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
status: 404,
|
||||
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' },
|
||||
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: '指定的项目文件不存在。' },
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(absolute);
|
||||
});
|
||||
|
||||
@@ -133,7 +133,10 @@ describe('Pi runtime Provider authentication recovery', () => {
|
||||
text: 'Do not leak credentials',
|
||||
attachments: [],
|
||||
})).rejects.toMatchObject({
|
||||
code: 'PI_RPC_RESPONSE_ERROR',
|
||||
publicError: {
|
||||
code: 'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => workers.length).toBe(2);
|
||||
|
||||
Reference in New Issue
Block a user