feat: remove legacy OpenCode runtime

Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
This commit is contained in:
2026-08-24 12:17:43 +08:00
parent 61fede2b4d
commit 5a275b93a7
199 changed files with 1330 additions and 64725 deletions

View File

@@ -5,6 +5,7 @@ import { createCodingConversationStore } from '../coding-projects/conversation-s
import { CodingProjectService } from '../coding-projects/project-service';
import {
createCodingProjectStore,
type CodingProjectStore,
type CodingProjectStorage,
} from '../coding-projects/project-store';
import { CodingConversationService } from '../coding-runtime/conversation-service';
@@ -31,6 +32,7 @@ import {
} from './coding-provider-auth';
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
export interface CodingCompositionPaths {
executablePath: string;
@@ -41,6 +43,7 @@ export interface CodingCompositionPaths {
export interface CreateCodingCompositionOptions {
storage: CodingProjectStorage;
projectStore?: CodingProjectStore;
browser: AgentBrowserModule;
paths: CodingCompositionPaths;
localProxyCredential?: string;
@@ -70,7 +73,7 @@ export function resolveCodingPiRuntimePaths(input: {
export function createCodingComposition(
options: CreateCodingCompositionOptions,
): CodingProductComposition {
const projectStore = createCodingProjectStore(options.storage);
const projectStore = options.projectStore ?? createCodingProjectStore(options.storage);
const attachments = new CodingAttachmentStore(
path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'),
);
@@ -156,6 +159,12 @@ export function createCodingComposition(
})),
});
const projects = new CodingProjectService(projectStore, {
migration: {
resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel(
legacyModel,
await getProviderService().listAccounts(),
),
},
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime.markResourcesStale();
@@ -195,6 +204,10 @@ export function createCodingComposition(
conversations,
runtime,
host,
async sleep() {
const conversationIds = runtime.getDiagnostics().workers.map((worker) => worker.conversationId);
await Promise.allSettled(conversationIds.map((conversationId) => runtime.dispose(conversationId)));
},
async shutdown() {
await subagents.close();
await runtime.shutdown();

View File

@@ -40,6 +40,7 @@ export interface CodingProductComposition {
conversations: CodingConversationService;
runtime: CodingConversationRuntime;
host: CodingProductHost;
sleep(): Promise<void>;
shutdown(): Promise<void>;
}

View File

@@ -1,6 +1,5 @@
import type { BrowserWindow } from 'electron';
import type { OpencodeManager } from '../opencode/manager';
import type { OpencodeProjectStore } from '../opencode/project-store';
import type { CodingProjectStore } from '../coding-projects/project-store';
import type { HostEventBus } from './event-bus';
import type { createWorksSubmissionBindingStore } from '../services/works-submission-binding';
import type { DesignWorkspaceModule } from '../image-workspace/module';
@@ -69,8 +68,7 @@ export interface AgentBrowserService {
}
export interface HostApiContext {
opencodeManager: OpencodeManager;
opencodeProjectStore: OpencodeProjectStore;
codingProjectStore: CodingProjectStore;
eventBus: HostEventBus;
mainWindow: BrowserWindow | null;
agentBrowser?: AgentBrowserService;

View File

@@ -7,7 +7,6 @@ export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean
const normalizedMethod = method.toUpperCase();
const pathname = path.split('?', 1)[0] || path;
if (pathname === '/api/events'
|| pathname === '/api/opencode/events'
|| pathname === '/api/coding/events') return true;
if (pathname === '/api/coding/attachments'
|| pathname.startsWith('/api/coding/attachments/')) return true;

View File

@@ -4,7 +4,6 @@ import { handleAiProxyRoutes } from './routes/ai-proxy';
import { handleAiHardwareRoutes } from './routes/ai-hardware';
import { handleAppRoutes } from './routes/app';
import { handleAuthRoutes } from './routes/auth';
import { handleOpencodeRoutes } from './routes/opencode';
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
import { handleLearningRoutes } from './routes/learning';
@@ -50,7 +49,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleCodingProjectRoutes,
handleCodingConversationRoutes,
handleCodingFileRoutes,
handleOpencodeRoutes,
handleSettingsRoutes,
handleProviderRoutes,
handleFileRoutes,

View File

@@ -3,7 +3,7 @@ import { PORTS } from '../utils/config';
/**
* Allowed CORS origins — only the Electron renderer (Vite dev or production)
* and the local opencode runtime are permitted to make cross-origin requests.
* and explicitly trusted local product surfaces may make cross-origin requests.
*/
const ALLOWED_ORIGINS = new Set([
`http://127.0.0.1:${PORTS.NIANCODE_DEV}`,
@@ -44,20 +44,11 @@ export function requireJsonContentType(req: IncomingMessage): boolean {
export function setCorsHeaders(
res: ServerResponse,
origin?: string,
runtimeUrl?: string,
): void {
// Only reflect the Origin header back if it is in the allow-list.
// Omitting the header for unknown origins causes the browser to block
// the response — this is the intended behavior for untrusted callers.
let runtimeOrigin: string | null = null;
if (runtimeUrl) {
try {
runtimeOrigin = new URL(runtimeUrl).origin;
} catch {
runtimeOrigin = null;
}
}
if (origin && (ALLOWED_ORIGINS.has(origin) || origin === runtimeOrigin)) {
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}

View File

@@ -1,7 +1,7 @@
import { realpath } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AgentBrowserBounds, AgentBrowserFaultShape } from '../../../shared/agent-browser';
import { normalizeProjectPath } from '../../opencode/project-store';
import { normalizeCodingProjectPath } from '../../coding-projects/project-store';
import type { HostApiContext } from '../context';
import { hasRendererCapability } from '../renderer-capability';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -71,7 +71,7 @@ function parseStringArray(value: unknown): string[] | undefined {
}
async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) {
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
const activeProject = await ctx.codingProjectStore.getActiveProject();
if (!activeProject) {
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409);
}
@@ -93,7 +93,7 @@ async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown
} catch {
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '请求的项目目录不可用。', 403);
}
if (normalizeProjectPath(requestedRealPath) !== normalizeProjectPath(activeRealPath)) {
if (normalizeCodingProjectPath(requestedRealPath) !== normalizeCodingProjectPath(activeRealPath)) {
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403);
}
@@ -107,7 +107,7 @@ async function ensureProjectStillActive(
ctx: HostApiContext,
project: { id: string; path: string },
): Promise<void> {
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
const activeProject = await ctx.codingProjectStore.getActiveProject();
let activeRealPath: string | null = null;
if (activeProject?.id === project.id) {
try {
@@ -119,7 +119,7 @@ async function ensureProjectStillActive(
if (
activeProject?.id === project.id
&& activeRealPath
&& normalizeProjectPath(activeRealPath) === normalizeProjectPath(project.path)
&& normalizeCodingProjectPath(activeRealPath) === normalizeCodingProjectPath(project.path)
) {
return;
}

View File

@@ -8,8 +8,8 @@ import {
} from '../../services/works-square-ai-gateway';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { logger } from '../../utils/logger';
import { getOpencodeErrorKind } from '../../../shared/opencode-error-kind';
import { isOpencodeUpstreamSaturated } from '../../../shared/opencode-error-details';
import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind';
import { isAIGatewayUpstreamSaturated } from '../../../shared/ai-gateway-error-details';
const AI_PROXY_PREFIX = '/api/ai-proxy/v1';
const MAX_ERROR_LOG_MESSAGE_LENGTH = 600;
@@ -79,10 +79,10 @@ function isExpiredGatewayTokenResponse(status: number, bodyText: string): boolea
}
function getForwardedOneApiStatus(status: number, bodyText: string): number {
if (status === 429 && getOpencodeErrorKind(bodyText) === 'quota_exhausted') {
if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') {
return 402;
}
if (status === 429 && isOpencodeUpstreamSaturated(bodyText)) {
if (status === 429 && isAIGatewayUpstreamSaturated(bodyText)) {
return 400;
}
return status;

View File

@@ -17,14 +17,13 @@ export async function handleAppRoutes(
});
res.write(': connected\n\n');
ctx.eventBus.addSseClient(res);
res.write(`event: opencode:status\ndata: ${JSON.stringify(ctx.opencodeManager.getStatus())}\n\n`);
return true;
}
if (url.pathname === '/api/app/runtime-info' && req.method === 'GET') {
sendJson(res, 200, {
runtime: 'opencode',
status: ctx.opencodeManager.getStatus(),
runtime: 'pi',
diagnostics: ctx.codingProducts?.runtime.getDiagnostics() ?? null,
});
return true;
}

View File

@@ -114,6 +114,16 @@ export async function handleCodingProjectRoutes(
});
return true;
}
if (url.pathname === '/api/coding/projects/legacy-conversation-notice/acknowledge'
&& req.method === 'POST') {
const body = await parseJsonBody<{ projectId?: string }>(req);
sendJson(res, 200, {
snapshot: publicProjectSnapshot(
await projects.acknowledgeLegacyConversationNotice(body.projectId ?? ''),
),
});
return true;
}
if (url.pathname === '/api/coding/projects/conversations' && req.method === 'GET') {
sendJson(res, 200, {
conversations: await conversations.listConversations(

View File

@@ -5,7 +5,6 @@ import { extname, join } from 'node:path';
import { homedir } from 'node:os';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { readProjectConfig } from '../../opencode/project-config';
import { loadGameAssetCandidates } from '../../coding-projects/game-asset-browser';
import {
GameAssetReviewConflictError,
@@ -128,13 +127,13 @@ export async function handleFileRoutes(
): Promise<boolean> {
if (url.pathname === '/api/files/game-asset-candidates' && req.method === 'GET') {
try {
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
const activeProject = await ctx.codingProjectStore.getActiveProject();
if (!activeProject) {
sendJson(res, 409, { success: false, error: 'No active project selected' });
return true;
}
const config = await readProjectConfig(activeProject.path);
if (config.status !== 'valid' || !config.config.initialized) {
const config = await ctx.codingProducts?.projects.getConfig(activeProject.id);
if (!config?.config.initialized) {
sendJson(res, 403, { success: false, error: 'Game asset browsing is unavailable for an uninitialized project' });
return true;
}
@@ -147,13 +146,13 @@ export async function handleFileRoutes(
if (url.pathname === '/api/files/game-asset-review' && (req.method === 'GET' || req.method === 'POST')) {
try {
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
const activeProject = await ctx.codingProjectStore.getActiveProject();
if (!activeProject) {
sendJson(res, 409, { success: false, error: 'No active project selected' });
return true;
}
const config = await readProjectConfig(activeProject.path);
if (config.status !== 'valid' || !config.config.initialized) {
const config = await ctx.codingProducts?.projects.getConfig(activeProject.id);
if (!config?.config.initialized) {
sendJson(res, 403, { success: false, error: 'Game asset review is unavailable for an uninitialized project' });
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,6 @@ import {
NIANCODE_USER_MODEL_ACCOUNT_LABEL,
normalizeImportedUserModelId,
} from '../../../shared/user-model-config';
import {
withRuntimeAcceptanceTimeout,
withRuntimeConfigCoordinator,
} from '../../opencode/runtime-config-readiness';
const legacyProviderRoutesWarned = new Set<string>();
@@ -150,7 +146,7 @@ type ImportedUserModelConfig = {
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
const WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE = 'works_square_ai_gateway_proxy';
const WORKS_SQUARE_AI_TOKEN_HEADER = 'X-Works-Square-AI-Token';
const NIANCODE_USER_MODEL_API_KEY_ENV = 'NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY';
const NIANCODE_USER_MODEL_API_KEY_ENV = 'NIANCODE_USER_MODELS_API_KEY';
class WorksSquareModelConfigError extends Error {
constructor(
@@ -242,7 +238,7 @@ function localAiProxyBaseUrl(): string {
}
async function fetchCurrentUserModelConfig(accessToken: string): Promise<ImportedUserModelConfig> {
const { response, payload } = await withRuntimeAcceptanceTimeout(async (signal) => {
const { response, payload } = await withProviderRequestTimeout(async (signal) => {
const response = await proxyAwareFetch(
createWorksUrl('/api/auth/me/model-config').toString(),
{
@@ -267,18 +263,21 @@ async function fetchCurrentUserModelConfig(accessToken: string): Promise<Importe
return normalizeImportedUserModelConfig(payload);
}
async function refreshRunningRuntimeAfterProviderChange(ctx: HostApiContext): Promise<void> {
async function refreshCodingRuntimeAfterProviderChange(ctx: HostApiContext): Promise<void> {
ctx.codingProducts?.conversations.markProviderStale();
if (ctx.opencodeManager.getStatus().state === 'stopped') return;
}
async function withProviderRequestTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs = 10_000,
): Promise<T> {
const controller = new AbortController();
const timeoutError = new Error('Provider operation timed out');
const timeout = setTimeout(() => controller.abort(timeoutError), timeoutMs);
try {
const status = await ctx.opencodeManager.restart();
if (status.state !== 'running' || typeof status.pid !== 'number') {
throw new Error('opencode runtime did not restart with the new provider configuration');
}
} catch (error) {
logger.warn('[providers] Failed to restart opencode runtime after provider configuration changed', error);
throw error;
return await operation(controller.signal);
} finally {
clearTimeout(timeout);
}
}
@@ -332,10 +331,7 @@ export async function importCurrentUserModelConfig(
}> {
const providerService = getProviderService();
const modelConfig = await fetchCurrentUserModelConfig(accessToken);
return await withRuntimeConfigCoordinator(ctx.opencodeManager, async (lease) => {
let operationMarkedPending = false;
try {
return await withRuntimeAcceptanceTimeout(async (signal) => {
return await withProviderRequestTimeout(async (signal) => {
signal.throwIfAborted();
const useLocalAiProxy = modelConfig.credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE;
const nowMs = Date.now();
@@ -362,23 +358,9 @@ export async function importCurrentUserModelConfig(
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const runtimeIsActive = ctx.opencodeManager.getStatus().state !== 'stopped';
const runtimeAlreadyUsesLocalProxyApiKey = useLocalAiProxy
&& ctx.opencodeManager.getRuntimeGenerationProvenance?.() === 'fresh';
const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account)
|| (!runtimeAlreadyUsesLocalProxyApiKey
&& await importedProviderApiKeyChanged(providerService, existing, accountApiKey));
const shouldRefreshRuntime = importedProviderRuntimeShapeChanged(existing, account)
|| await importedProviderApiKeyChanged(providerService, existing, accountApiKey);
signal.throwIfAborted();
const refreshAlreadyPending = lease.isRefreshPending();
const runtimeRefreshRequired = runtimeIsActive
&& (shouldRestartRuntime || refreshAlreadyPending);
const armStoppedApplyForNextFresh = !runtimeIsActive
&& refreshAlreadyPending
&& options.runtimeRefresh !== 'defer';
if (runtimeRefreshRequired) {
lease.markRefreshPending();
operationMarkedPending = true;
}
signal.throwIfAborted();
if (useLocalAiProxy) {
@@ -399,28 +381,16 @@ export async function importCurrentUserModelConfig(
signal.throwIfAborted();
await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
if (!runtimeRefreshRequired || options.runtimeRefresh === 'defer') {
ctx.codingProducts?.conversations.markProviderStale();
}
signal.throwIfAborted();
if (armStoppedApplyForNextFresh) {
lease.markRefreshPending();
}
if (runtimeRefreshRequired && options.runtimeRefresh !== 'defer') {
await refreshRunningRuntimeAfterProviderChange(ctx);
if (shouldRefreshRuntime) {
await refreshCodingRuntimeAfterProviderChange(ctx);
signal.throwIfAborted();
}
return {
account: savedAccount,
importedModels: modelConfig.models,
runtimeRefreshRequired,
runtimeRefreshRequired: shouldRefreshRuntime && options.runtimeRefresh === 'defer',
};
});
} catch (error) {
if (operationMarkedPending) lease.retainRefreshPending();
throw error;
}
});
}
@@ -453,7 +423,7 @@ export async function handleProviderRoutes(
try {
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
const account = await providerService.createAccount(body.account, body.apiKey);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true, account });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -475,7 +445,7 @@ export async function handleProviderRoutes(
return true;
}
await providerService.setDefaultAccount(body.accountId);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -629,7 +599,7 @@ export async function handleProviderRoutes(
return true;
}
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true, account: nextAccount });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -642,12 +612,12 @@ export async function handleProviderRoutes(
try {
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService._deleteProviderApiKeyInternal(accountId);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
return true;
}
await providerService.deleteAccount(accountId);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -677,7 +647,7 @@ export async function handleProviderRoutes(
return true;
}
await providerService._setDefaultProviderInternal(body.providerId);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -765,7 +735,7 @@ export async function handleProviderRoutes(
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
}
}
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -815,7 +785,7 @@ export async function handleProviderRoutes(
await providerService._deleteProviderApiKeyInternal(providerId);
}
}
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -830,12 +800,12 @@ export async function handleProviderRoutes(
await providerService._getProviderInternal(providerId);
if (url.searchParams.get('apiKeyOnly') === '1') {
await providerService._deleteProviderApiKeyInternal(providerId);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
return true;
}
await providerService._deleteProviderInternal(providerId);
await refreshRunningRuntimeAfterProviderChange(ctx);
await refreshCodingRuntimeAfterProviderChange(ctx);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });

View File

@@ -525,7 +525,7 @@ async function handleDownloadAssetToProject(
const body = await parseJsonBody<DownloadAssetInput>(req);
const projectId = readRequiredString(body.projectId, 'projectId');
const fileName = assetZipFileName(slug);
const projects = await ctx.opencodeProjectStore.listProjects();
const projects = await ctx.codingProjectStore.listProjects();
const project = projects.find((candidate) => candidate.id === projectId);
if (!project) {
throw new Error('Project not found');
@@ -1081,7 +1081,7 @@ async function handlePublishProjectSource(
return;
}
const appId = projectMetadata.app_id as string;
const localProject = (await ctx.opencodeProjectStore.listProjects())
const localProject = (await ctx.codingProjectStore.listProjects())
.find((candidate) => candidate.id === projectId);
if (!localProject) {
sendPublishSourceFailure(res, 404, 'PROJECT_NOT_FOUND', '本地项目不存在,请重新选择项目。');
@@ -1266,7 +1266,7 @@ async function handleStartReleaseJob(
sendJson(res, 400, { success: false, code: 'PROJECT_ID_REQUIRED', error: '缺少项目标识。' });
return;
}
const project = (await ctx.opencodeProjectStore.listProjects()).find((candidate) => candidate.id === projectId);
const project = (await ctx.codingProjectStore.listProjects()).find((candidate) => candidate.id === projectId);
if (!project) {
sendJson(res, 404, { success: false, code: 'PROJECT_NOT_FOUND', error: '本地项目不存在。' });
return;

View File

@@ -33,7 +33,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE
// Set origin-aware CORS headers early so every response
// (including error responses) carries them consistently.
const origin = req.headers.origin;
setCorsHeaders(res, origin, ctx.opencodeManager.getStatus().url);
setCorsHeaders(res, origin);
// CORS preflight — respond before auth so browsers can negotiate.
if (req.method === 'OPTIONS') {