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

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