fix: isolate concurrent OpenCode chat runs
This commit is contained in:
@@ -32,9 +32,20 @@ import { getHostApiToken } from '../server';
|
||||
import {
|
||||
listProjectKnowledge,
|
||||
readProjectConfig,
|
||||
readProjectConfigSnapshot,
|
||||
writeProjectConfig,
|
||||
type ProjectConfigReadResult,
|
||||
} from '../../opencode/project-config';
|
||||
import {
|
||||
acceptProjectAgentRuntime,
|
||||
mutateProjectAgentRuntime,
|
||||
observeProjectAgentRuntime,
|
||||
observeProjectAgentRuntimeGeneration,
|
||||
} from '../../opencode/project-agent-runtime';
|
||||
import {
|
||||
withRuntimeAcceptanceTimeout,
|
||||
withRuntimeConfigCoordinator,
|
||||
} from '../../opencode/runtime-config-readiness';
|
||||
import {
|
||||
readProjectConversationState,
|
||||
writeProjectConversationState,
|
||||
@@ -59,6 +70,7 @@ const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
|
||||
type ActiveProjectClient = ReturnType<typeof createOpencodeClient>;
|
||||
type OpencodePromptModel = NonNullable<SendOpencodeSessionMessageInput['model']>;
|
||||
type RuntimeStatus = ReturnType<HostApiContext['opencodeManager']['getStatus']>;
|
||||
const activeProjectPathByClient = new WeakMap<object, string>();
|
||||
|
||||
interface ExpectedUserModelProxyConfig {
|
||||
usesLocalProxy: boolean;
|
||||
@@ -256,12 +268,29 @@ async function loadRuntimeConfigSummaryForPrompt(): Promise<OpencodeRuntimeConfi
|
||||
}
|
||||
}
|
||||
|
||||
let directWorksSquareCredentialRefreshPromise: Promise<void> | null = null;
|
||||
const directWorksSquareCredentialRefreshPromises = new WeakMap<
|
||||
object,
|
||||
Record<'apply' | 'defer', Promise<boolean> | null>
|
||||
>();
|
||||
|
||||
async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise<void> {
|
||||
function credentialRefreshPromisesForManager(
|
||||
manager: object,
|
||||
): Record<'apply' | 'defer', Promise<boolean> | null> {
|
||||
let promises = directWorksSquareCredentialRefreshPromises.get(manager);
|
||||
if (!promises) {
|
||||
promises = { apply: null, defer: null };
|
||||
directWorksSquareCredentialRefreshPromises.set(manager, promises);
|
||||
}
|
||||
return promises;
|
||||
}
|
||||
|
||||
async function refreshDirectWorksSquareCredentialBeforePrompt(
|
||||
ctx: HostApiContext,
|
||||
runtimeRefresh: 'apply' | 'defer',
|
||||
): Promise<boolean> {
|
||||
const account = await getProviderService().getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
if (account?.metadata?.worksSquareCredentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = Date.parse(account.metadata.worksSquareCredentialExpiresAt ?? '');
|
||||
@@ -269,7 +298,7 @@ async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContex
|
||||
Number.isFinite(expiresAt)
|
||||
&& expiresAt > Date.now() + DIRECT_WORKS_SQUARE_CREDENTIAL_REFRESH_SKEW_MS
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const accessToken = await getValidWorksSquareAccessToken();
|
||||
@@ -277,18 +306,88 @@ async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContex
|
||||
throw new Error('Works Square access token unavailable; please log in again');
|
||||
}
|
||||
|
||||
await importCurrentUserModelConfig(ctx, accessToken);
|
||||
const result = await importCurrentUserModelConfig(ctx, accessToken, { runtimeRefresh });
|
||||
logger.info('[opencode-route] Refreshed direct Works Square AI gateway credential before prompt');
|
||||
return result.runtimeRefreshRequired;
|
||||
}
|
||||
|
||||
async function ensureDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise<void> {
|
||||
if (!directWorksSquareCredentialRefreshPromise) {
|
||||
directWorksSquareCredentialRefreshPromise = refreshDirectWorksSquareCredentialBeforePrompt(ctx)
|
||||
async function ensureDirectWorksSquareCredentialBeforePrompt(
|
||||
ctx: HostApiContext,
|
||||
runtimeRefresh: 'apply' | 'defer' = 'apply',
|
||||
): Promise<boolean> {
|
||||
const promises = credentialRefreshPromisesForManager(ctx.opencodeManager as object);
|
||||
if (!promises[runtimeRefresh]) {
|
||||
promises[runtimeRefresh] = refreshDirectWorksSquareCredentialBeforePrompt(
|
||||
ctx,
|
||||
runtimeRefresh,
|
||||
)
|
||||
.finally(() => {
|
||||
directWorksSquareCredentialRefreshPromise = null;
|
||||
promises[runtimeRefresh] = null;
|
||||
});
|
||||
}
|
||||
await directWorksSquareCredentialRefreshPromise;
|
||||
return await promises[runtimeRefresh];
|
||||
}
|
||||
|
||||
async function prepareRuntimeExecution(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (await ensureDirectWorksSquareCredentialBeforePrompt(ctx, 'defer')) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[opencode-route] Failed to defer runtime credential refresh', error);
|
||||
sendRuntimeConfigPending(res, ctx);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function coordinateRuntimeExecution(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
operation: (
|
||||
signal: AbortSignal,
|
||||
markPending: () => void,
|
||||
response: ServerResponse,
|
||||
) => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (!await prepareRuntimeExecution(res, ctx)) return false;
|
||||
return await withRuntimeConfigCoordinator(ctx.opencodeManager, async (lease) => {
|
||||
if (lease.isRefreshPending()) {
|
||||
sendRuntimeConfigPending(res, ctx);
|
||||
return false;
|
||||
}
|
||||
let operationMarkedPending = false;
|
||||
try {
|
||||
await withRuntimeAcceptanceTimeout(async (signal) => {
|
||||
const guardedResponse = new Proxy(res, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args: unknown[]) => {
|
||||
if (signal.aborted) return property === 'write' ? false : undefined;
|
||||
return Reflect.apply(value, target, args);
|
||||
};
|
||||
},
|
||||
set(target, property, value, receiver) {
|
||||
if (signal.aborted) return true;
|
||||
return Reflect.set(target, property, value, receiver);
|
||||
},
|
||||
});
|
||||
const markPending = () => {
|
||||
operationMarkedPending = true;
|
||||
lease.markRefreshPending();
|
||||
};
|
||||
await operation(signal, markPending, guardedResponse);
|
||||
});
|
||||
} catch (error) {
|
||||
if (operationMarkedPending) lease.retainRefreshPending();
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRuntimePromptModel(
|
||||
@@ -409,9 +508,14 @@ function expectedUserModelProxyConfig(
|
||||
};
|
||||
}
|
||||
|
||||
async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Promise<boolean> {
|
||||
async function rebindLocalProxyHostApiTokenIfNeeded(
|
||||
expectedBaseUrl: string,
|
||||
markPending: () => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const providerService = getProviderService();
|
||||
const account = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
signal?.throwIfAborted();
|
||||
const accountBaseUrl = normalizeRuntimeBaseUrl(account?.baseUrl);
|
||||
if (!account || accountBaseUrl !== expectedBaseUrl) {
|
||||
return false;
|
||||
@@ -423,15 +527,18 @@ async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Pr
|
||||
}
|
||||
|
||||
const existingApiKey = await providerService.getAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
signal?.throwIfAborted();
|
||||
if (existingApiKey === currentHostApiToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
markPending();
|
||||
await providerService.updateAccount(
|
||||
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
||||
account,
|
||||
currentHostApiToken,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -439,13 +546,16 @@ async function runtimeUsesExpectedUserModelProxy(
|
||||
status: RuntimeStatus,
|
||||
directory: string,
|
||||
expectedConfig: ExpectedUserModelProxyConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
if (status.state !== 'running' || !status.url) return false;
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: status.url,
|
||||
directory,
|
||||
});
|
||||
const runtimeConfig = await client.getConfig();
|
||||
signal?.throwIfAborted();
|
||||
const runtimeConfig = await client.getConfig({ signal });
|
||||
signal?.throwIfAborted();
|
||||
const actualBaseUrl = providerBaseUrlFromRuntimeConfig(
|
||||
runtimeConfig,
|
||||
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
||||
@@ -473,6 +583,9 @@ async function ensureRuntimeUserModelProxyConfig(
|
||||
status: RuntimeStatus,
|
||||
directory: string,
|
||||
summary?: OpencodeRuntimeConfigSummary,
|
||||
allowRestart = true,
|
||||
markPending: () => void = () => undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RuntimeStatus | null> {
|
||||
let expectedConfig: ExpectedUserModelProxyConfig | null;
|
||||
try {
|
||||
@@ -480,7 +593,13 @@ async function ensureRuntimeUserModelProxyConfig(
|
||||
summary ?? await buildNianCodeRuntimeConfigSummary(),
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
logger.warn('[opencode-route] Failed to inspect generated runtime provider config', error);
|
||||
if (!allowRestart) {
|
||||
markPending();
|
||||
sendRuntimeConfigPending(res, ctx);
|
||||
return null;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
if (!expectedConfig) return status;
|
||||
@@ -488,8 +607,13 @@ async function ensureRuntimeUserModelProxyConfig(
|
||||
try {
|
||||
if (
|
||||
expectedConfig.usesLocalProxy
|
||||
&& await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl)
|
||||
&& await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl, markPending, signal)
|
||||
) {
|
||||
if (!allowRestart) {
|
||||
markPending();
|
||||
sendRuntimeConfigPending(res, ctx);
|
||||
return null;
|
||||
}
|
||||
logger.warn('[opencode-route] Restarting runtime because local AI proxy Host API token changed');
|
||||
const restartedStatus = await ctx.opencodeManager.restart();
|
||||
if (restartedStatus.state !== 'running' || !restartedStatus.url) {
|
||||
@@ -502,17 +626,30 @@ async function ensureRuntimeUserModelProxyConfig(
|
||||
status = restartedStatus;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
logger.warn('[opencode-route] Failed to rebind local AI proxy Host API token', error);
|
||||
if (!allowRestart) {
|
||||
markPending();
|
||||
sendRuntimeConfigPending(res, ctx);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig)) {
|
||||
if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig, signal)) {
|
||||
return status;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
logger.warn('[opencode-route] Failed to inspect running runtime provider config', error);
|
||||
}
|
||||
|
||||
if (!allowRestart) {
|
||||
markPending();
|
||||
sendRuntimeConfigPending(res, ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.warn('[opencode-route] Restarting runtime because user model provider config is stale', {
|
||||
expectedModelIds: expectedConfig.modelIds,
|
||||
expectedImageInputModelIds: expectedConfig.imageInputModelIds,
|
||||
@@ -528,10 +665,11 @@ async function ensureRuntimeUserModelProxyConfig(
|
||||
}
|
||||
|
||||
try {
|
||||
if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig)) {
|
||||
if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig, signal)) {
|
||||
return restartedStatus;
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
logger.warn('[opencode-route] Failed to inspect restarted runtime provider config', error);
|
||||
}
|
||||
|
||||
@@ -542,6 +680,30 @@ async function ensureRuntimeUserModelProxyConfig(
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendRuntimeConfigPending(res: ServerResponse, ctx: HostApiContext): void {
|
||||
sendJson(res, 409, {
|
||||
success: false,
|
||||
error: '运行时配置已更新。请在当前回复完成后手动重启运行时再试。',
|
||||
code: 'OPENCODE_RUNTIME_CONFIG_PENDING',
|
||||
promptSent: false,
|
||||
terminal: true,
|
||||
retryable: false,
|
||||
runtimeGeneration: ctx.opencodeManager.getRuntimeGeneration?.() ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function sendAgentRegistryPending(res: ServerResponse, runtimeGeneration: number): void {
|
||||
sendJson(res, 409, {
|
||||
success: false,
|
||||
error: '项目 Agent 配置正在等待运行时重新加载,请在当前回复完成后手动重启运行时再试。',
|
||||
code: 'OPENCODE_AGENT_REGISTRY_PENDING',
|
||||
promptSent: false,
|
||||
terminal: true,
|
||||
retryable: false,
|
||||
runtimeGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
function buildNewProjectPath(parentPath: string | undefined, projectName: string | undefined): string {
|
||||
const normalizedParent = parentPath?.trim();
|
||||
const normalizedName = projectName?.trim();
|
||||
@@ -652,8 +814,11 @@ function sendProjectActivationFailure(
|
||||
async function getValidatedActiveProjectForRuntime<TProject extends { path: string }>(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TProject | null> {
|
||||
signal?.throwIfAborted();
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject() as TProject | null;
|
||||
signal?.throwIfAborted();
|
||||
if (!activeProject) {
|
||||
sendJson(res, 409, {
|
||||
success: false,
|
||||
@@ -663,16 +828,25 @@ async function getValidatedActiveProjectForRuntime<TProject extends { path: stri
|
||||
}
|
||||
|
||||
const projectCheck = await validateProjectForActivation(activeProject);
|
||||
signal?.throwIfAborted();
|
||||
if (!projectCheck.ok) {
|
||||
sendProjectActivationFailure(res, projectCheck);
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = await readProjectConfig(activeProject.path);
|
||||
signal?.throwIfAborted();
|
||||
if (config.status !== 'valid' || !config.config.initialized) {
|
||||
sendJson(res, 409, { success: false, error: 'Project initialization is incomplete', projectStatus: 'incomplete' });
|
||||
return null;
|
||||
}
|
||||
await observeProjectAgentRuntime(
|
||||
ctx.opencodeManager,
|
||||
activeProject.path,
|
||||
config.config,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
return activeProject;
|
||||
}
|
||||
@@ -680,7 +854,12 @@ async function getValidatedActiveProjectForRuntime<TProject extends { path: stri
|
||||
async function createClientForActiveProject(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
options: { ensureUserModelProxyConfig?: boolean } = {},
|
||||
options: {
|
||||
ensureUserModelProxyConfig?: boolean;
|
||||
allowRuntimeRestart?: boolean;
|
||||
markRuntimeConfigPending?: () => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
runtimeConfigSummary?: OpencodeRuntimeConfigSummary,
|
||||
): Promise<ActiveProjectClient | null> {
|
||||
let status = ctx.opencodeManager.getStatus();
|
||||
@@ -692,7 +871,9 @@ async function createClientForActiveProject(
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProject = await getValidatedActiveProjectForRuntime(res, ctx);
|
||||
options.signal?.throwIfAborted();
|
||||
const activeProject = await getValidatedActiveProjectForRuntime(res, ctx, options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
@@ -703,15 +884,28 @@ async function createClientForActiveProject(
|
||||
status,
|
||||
activeProject.path,
|
||||
runtimeConfigSummary,
|
||||
options.allowRuntimeRestart,
|
||||
options.markRuntimeConfigPending,
|
||||
options.signal,
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
if (!ensuredStatus) return null;
|
||||
status = ensuredStatus;
|
||||
await observeProjectAgentRuntimeGeneration(
|
||||
ctx.opencodeManager,
|
||||
activeProject.path,
|
||||
options.signal,
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
return createOpencodeClient({
|
||||
options.signal?.throwIfAborted();
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: status.url,
|
||||
directory: activeProject.path,
|
||||
});
|
||||
activeProjectPathByClient.set(client, activeProject.path);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function getActiveProjectContext(res: ServerResponse, ctx: HostApiContext) {
|
||||
@@ -1072,7 +1266,19 @@ export async function handleOpencodeRoutes(
|
||||
if (!body.projectId || !body.config) throw new Error('Missing project configuration');
|
||||
const project = await findProjectById(ctx, body.projectId);
|
||||
if (!project) throw new Error('Project not found');
|
||||
const config = await writeProjectConfig(project.path, body.config);
|
||||
const config = await mutateProjectAgentRuntime(
|
||||
ctx.opencodeManager,
|
||||
project.path,
|
||||
async () => {
|
||||
const previous = await readProjectConfigSnapshot(project.path);
|
||||
const saved = await writeProjectConfig(project.path, body.config);
|
||||
return {
|
||||
previousConfig: previous.status === 'valid' ? previous.config : null,
|
||||
config: saved,
|
||||
value: saved,
|
||||
};
|
||||
},
|
||||
);
|
||||
const status = ctx.opencodeManager.getStatus();
|
||||
sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status });
|
||||
} catch (error) {
|
||||
@@ -1323,29 +1529,42 @@ export async function handleOpencodeRoutes(
|
||||
);
|
||||
if (sessionSummarizeMatch && req.method === 'POST') {
|
||||
try {
|
||||
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
|
||||
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
|
||||
const client = await createClientForActiveProject(
|
||||
res,
|
||||
ctx,
|
||||
{ ensureUserModelProxyConfig: true },
|
||||
runtimeConfigSummary,
|
||||
);
|
||||
if (!client) return true;
|
||||
const body = await parseJsonBody<{ model?: unknown }>(req);
|
||||
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
|
||||
if (body.model !== undefined && !explicitModel) {
|
||||
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
|
||||
return true;
|
||||
}
|
||||
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
|
||||
if (!model) {
|
||||
sendJson(res, 409, { success: false, error: 'No runtime model configured' });
|
||||
return true;
|
||||
}
|
||||
const sessionID = decodeURIComponent(sessionSummarizeMatch[1]);
|
||||
await client.summarizeSession(sessionID, model);
|
||||
sendJson(res, 202, { success: true });
|
||||
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
|
||||
signal.throwIfAborted();
|
||||
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
|
||||
signal.throwIfAborted();
|
||||
const client = await createClientForActiveProject(
|
||||
response,
|
||||
ctx,
|
||||
{
|
||||
ensureUserModelProxyConfig: true,
|
||||
allowRuntimeRestart: false,
|
||||
markRuntimeConfigPending: markPending,
|
||||
signal,
|
||||
},
|
||||
runtimeConfigSummary,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (!client) return;
|
||||
const body = await parseJsonBody<{ model?: unknown }>(req);
|
||||
signal.throwIfAborted();
|
||||
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
|
||||
if (body.model !== undefined && !explicitModel) {
|
||||
sendJson(response, 400, { success: false, error: 'Invalid model reference' });
|
||||
return;
|
||||
}
|
||||
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
|
||||
signal.throwIfAborted();
|
||||
if (!model) {
|
||||
sendJson(response, 409, { success: false, error: 'No runtime model configured' });
|
||||
return;
|
||||
}
|
||||
const sessionID = decodeURIComponent(sessionSummarizeMatch[1]);
|
||||
signal.throwIfAborted();
|
||||
await client.summarizeSession(sessionID, model, { signal });
|
||||
signal.throwIfAborted();
|
||||
sendJson(response, 202, { success: true });
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
@@ -1357,15 +1576,23 @@ export async function handleOpencodeRoutes(
|
||||
);
|
||||
if (sessionCommandMatch && req.method === 'POST') {
|
||||
try {
|
||||
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
|
||||
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
|
||||
const client = await createClientForActiveProject(
|
||||
res,
|
||||
ctx,
|
||||
{ ensureUserModelProxyConfig: true },
|
||||
runtimeConfigSummary,
|
||||
);
|
||||
if (!client) return true;
|
||||
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
|
||||
signal.throwIfAborted();
|
||||
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
|
||||
signal.throwIfAborted();
|
||||
const client = await createClientForActiveProject(
|
||||
response,
|
||||
ctx,
|
||||
{
|
||||
ensureUserModelProxyConfig: true,
|
||||
allowRuntimeRestart: false,
|
||||
markRuntimeConfigPending: markPending,
|
||||
signal,
|
||||
},
|
||||
runtimeConfigSummary,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (!client) return;
|
||||
const body = await parseJsonBody<{
|
||||
command?: unknown;
|
||||
arguments?: unknown;
|
||||
@@ -1374,6 +1601,7 @@ export async function handleOpencodeRoutes(
|
||||
variant?: unknown;
|
||||
parts?: unknown;
|
||||
}>(req);
|
||||
signal.throwIfAborted();
|
||||
const command = parseRequiredCommandName(body.command);
|
||||
const argumentsText = typeof body.arguments === 'string'
|
||||
&& body.arguments.length <= MAX_COMMAND_ARGUMENTS
|
||||
@@ -1381,8 +1609,8 @@ export async function handleOpencodeRoutes(
|
||||
: null;
|
||||
const parts = normalizeCommandParts(body.parts);
|
||||
if (!command || argumentsText === null || !parts) {
|
||||
sendJson(res, 400, { success: false, error: 'Invalid command payload' });
|
||||
return true;
|
||||
sendJson(response, 400, { success: false, error: 'Invalid command payload' });
|
||||
return;
|
||||
}
|
||||
const sessionID = decodeURIComponent(sessionCommandMatch[1]);
|
||||
const agent = parseOptionalCommandIdentifier(body.agent);
|
||||
@@ -1394,24 +1622,49 @@ export async function handleOpencodeRoutes(
|
||||
|| (body.variant !== undefined && !variant)
|
||||
);
|
||||
if (invalidOptionalField) {
|
||||
sendJson(res, 400, { success: false, error: 'Invalid command runtime context' });
|
||||
return true;
|
||||
sendJson(response, 400, { success: false, error: 'Invalid command runtime context' });
|
||||
return;
|
||||
}
|
||||
logger.info('[opencode-route] Executing session command', {
|
||||
sessionID,
|
||||
command,
|
||||
partCount: parts.length,
|
||||
fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []),
|
||||
const executeCommand = async (): Promise<void> => {
|
||||
logger.info('[opencode-route] Executing session command', {
|
||||
sessionID,
|
||||
command,
|
||||
partCount: parts.length,
|
||||
fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []),
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
await client.executeSessionCommand(sessionID, {
|
||||
command,
|
||||
arguments: argumentsText,
|
||||
...(agent ? { agent } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(variant ? { variant } : {}),
|
||||
...(parts.length ? { parts } : {}),
|
||||
}, { signal });
|
||||
signal.throwIfAborted();
|
||||
};
|
||||
if (agent) {
|
||||
const projectPath = activeProjectPathByClient.get(client);
|
||||
if (!projectPath) throw new Error('Active project runtime context is unavailable');
|
||||
const acceptance = await acceptProjectAgentRuntime(
|
||||
ctx.opencodeManager,
|
||||
projectPath,
|
||||
client,
|
||||
agent,
|
||||
executeCommand,
|
||||
signal,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (!acceptance.ready) {
|
||||
sendAgentRegistryPending(response, acceptance.runtimeGeneration);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await executeCommand();
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
sendJson(response, 202, { success: true });
|
||||
});
|
||||
await client.executeSessionCommand(sessionID, {
|
||||
command,
|
||||
arguments: argumentsText,
|
||||
...(agent ? { agent } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(variant ? { variant } : {}),
|
||||
...(parts.length ? { parts } : {}),
|
||||
});
|
||||
sendJson(res, 202, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
@@ -1696,15 +1949,23 @@ export async function handleOpencodeRoutes(
|
||||
|
||||
if (sessionMessagesMatch && req.method === 'POST') {
|
||||
try {
|
||||
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
|
||||
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
|
||||
const client = await createClientForActiveProject(
|
||||
res,
|
||||
ctx,
|
||||
{ ensureUserModelProxyConfig: true },
|
||||
runtimeConfigSummary,
|
||||
);
|
||||
if (!client) return true;
|
||||
await coordinateRuntimeExecution(res, ctx, async (signal, markPending, response) => {
|
||||
signal.throwIfAborted();
|
||||
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
|
||||
signal.throwIfAborted();
|
||||
const client = await createClientForActiveProject(
|
||||
response,
|
||||
ctx,
|
||||
{
|
||||
ensureUserModelProxyConfig: true,
|
||||
allowRuntimeRestart: false,
|
||||
markRuntimeConfigPending: markPending,
|
||||
signal,
|
||||
},
|
||||
runtimeConfigSummary,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (!client) return;
|
||||
const body = await parseJsonBody<{
|
||||
text?: string;
|
||||
files?: unknown;
|
||||
@@ -1712,39 +1973,66 @@ export async function handleOpencodeRoutes(
|
||||
agent?: unknown;
|
||||
model?: unknown;
|
||||
}>(req);
|
||||
signal.throwIfAborted();
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
if (!text) {
|
||||
sendJson(res, 400, { success: false, error: 'Missing message text' });
|
||||
return true;
|
||||
sendJson(response, 400, { success: false, error: 'Missing message text' });
|
||||
return;
|
||||
}
|
||||
const sessionID = decodeURIComponent(sessionMessagesMatch[1]);
|
||||
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
|
||||
if (body.model !== undefined && !explicitModel) {
|
||||
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
|
||||
return true;
|
||||
sendJson(response, 400, { success: false, error: 'Invalid model reference' });
|
||||
return;
|
||||
}
|
||||
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
|
||||
signal.throwIfAborted();
|
||||
const files = normalizePromptFileParts(body.files);
|
||||
const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : '';
|
||||
const agent = typeof body.agent === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agent.trim())
|
||||
? body.agent.trim()
|
||||
: '';
|
||||
logger.info('[opencode-route] Starting session prompt', {
|
||||
sessionID,
|
||||
runtimeUrl: ctx.opencodeManager.getStatus().url ?? null,
|
||||
model: promptModelLogRef(model),
|
||||
textLength: text.length,
|
||||
fileCount: files.length,
|
||||
fileMimes: files.map((file) => file.mime),
|
||||
const acceptPrompt = async (): Promise<void> => {
|
||||
logger.info('[opencode-route] Starting session prompt', {
|
||||
sessionID,
|
||||
runtimeUrl: ctx.opencodeManager.getStatus().url ?? null,
|
||||
model: promptModelLogRef(model),
|
||||
textLength: text.length,
|
||||
fileCount: files.length,
|
||||
fileMimes: files.map((file) => file.mime),
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
await client.promptSessionAsync(sessionID, {
|
||||
text,
|
||||
...(userContext ? { system: userContext } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
...(files.length ? { files } : {}),
|
||||
...(model ? { model } : {}),
|
||||
}, { signal });
|
||||
signal.throwIfAborted();
|
||||
};
|
||||
if (agent) {
|
||||
const projectPath = activeProjectPathByClient.get(client);
|
||||
if (!projectPath) throw new Error('Active project runtime context is unavailable');
|
||||
const acceptance = await acceptProjectAgentRuntime(
|
||||
ctx.opencodeManager,
|
||||
projectPath,
|
||||
client,
|
||||
agent,
|
||||
acceptPrompt,
|
||||
signal,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (!acceptance.ready) {
|
||||
sendAgentRegistryPending(response, acceptance.runtimeGeneration);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await acceptPrompt();
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
sendJson(response, 202, { success: true });
|
||||
});
|
||||
await client.promptSessionAsync(sessionID, {
|
||||
text,
|
||||
...(userContext ? { system: userContext } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
...(files.length ? { files } : {}),
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
sendJson(res, 202, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
@@ -1758,7 +2046,10 @@ export async function handleOpencodeRoutes(
|
||||
|
||||
if (url.pathname === '/api/opencode/start' && req.method === 'POST') {
|
||||
try {
|
||||
const status = await ctx.opencodeManager.start();
|
||||
const status = await withRuntimeConfigCoordinator(
|
||||
ctx.opencodeManager,
|
||||
async () => await ctx.opencodeManager.start(),
|
||||
);
|
||||
sendJson(res, 200, { success: true, status });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -1768,7 +2059,10 @@ export async function handleOpencodeRoutes(
|
||||
|
||||
if (url.pathname === '/api/opencode/stop' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.opencodeManager.stop();
|
||||
await withRuntimeConfigCoordinator(
|
||||
ctx.opencodeManager,
|
||||
async () => await ctx.opencodeManager.stop(),
|
||||
);
|
||||
sendJson(res, 200, { success: true, status: ctx.opencodeManager.getStatus() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -1778,7 +2072,10 @@ export async function handleOpencodeRoutes(
|
||||
|
||||
if (url.pathname === '/api/opencode/restart' && req.method === 'POST') {
|
||||
try {
|
||||
const status = await ctx.opencodeManager.restart();
|
||||
const status = await withRuntimeConfigCoordinator(
|
||||
ctx.opencodeManager,
|
||||
async () => await ctx.opencodeManager.restart(),
|
||||
);
|
||||
sendJson(res, 200, { success: true, status });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
|
||||
Reference in New Issue
Block a user