fix(coding): preserve runtime model and failure contracts
This commit is contained in:
@@ -8,33 +8,53 @@ import {
|
||||
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 class CodingProviderCredentialRefreshError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'CodingProviderCredentialRefreshError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodingProviderAuthenticationError(error: unknown): boolean {
|
||||
return error instanceof Error && AUTHENTICATION_ERROR_PATTERN.test(error.message);
|
||||
return error instanceof CodingProviderCredentialRefreshError
|
||||
|| (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');
|
||||
try {
|
||||
const providerService = getProviderService();
|
||||
const account = await providerService.getAccount(accountId);
|
||||
if (!account?.enabled) {
|
||||
throw new CodingProviderCredentialRefreshError('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;
|
||||
}
|
||||
if (account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
||||
markWorksSquareAIGatewayCredentialExpired();
|
||||
const credential = await getFreshWorksSquareAIGatewayCredential();
|
||||
if (!credential) {
|
||||
throw new CodingProviderCredentialRefreshError('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');
|
||||
const current = await resolvePiProviderCredentialFromSecretStore(account);
|
||||
if (!current && account.authMode !== 'local') {
|
||||
throw new CodingProviderCredentialRefreshError('Provider credential is unavailable');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CodingProviderCredentialRefreshError) throw error;
|
||||
throw new CodingProviderCredentialRefreshError(
|
||||
'Provider credential refresh failed',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,11 +339,24 @@ export class CodingConversationService {
|
||||
}
|
||||
|
||||
async setModel(conversationId: string, model: ProductModelRef): Promise<ConversationModelState> {
|
||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
||||
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
let selected: ProductModelRef;
|
||||
try {
|
||||
selected = await this.runtime.validateModel(model);
|
||||
} catch (error) { runtimeError(error); }
|
||||
if (conversation.modelResolution === 'resolved' && conversation.model) {
|
||||
await this.ensurePrepared(conversationId);
|
||||
let state: ConversationModelState;
|
||||
try {
|
||||
state = await this.runtime.setModel({
|
||||
conversationId,
|
||||
accountId: selected.accountId,
|
||||
modelId: selected.modelId,
|
||||
});
|
||||
} catch (error) { runtimeError(error); }
|
||||
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
|
||||
return state;
|
||||
}
|
||||
const state: ConversationModelState = {
|
||||
model: selected,
|
||||
modelResolution: 'resolved',
|
||||
@@ -364,7 +377,7 @@ export class CodingConversationService {
|
||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
||||
try {
|
||||
const state = await this.runtime.setThinking({ conversationId, thinkingLevel });
|
||||
await this.projects.conversationStore(project.path).setModelState(conversationId, state);
|
||||
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
|
||||
return state;
|
||||
} catch (error) { runtimeError(error); }
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import type {
|
||||
CodingConversationRuntime,
|
||||
CodingRuntimeCommand,
|
||||
CodingRuntimeDiagnostics,
|
||||
CodingRuntimeErrorCode,
|
||||
CodingRuntimePublicError,
|
||||
ConversationModelState,
|
||||
ConversationInteraction,
|
||||
ConversationInteractionResponse,
|
||||
@@ -22,22 +20,14 @@ import type {
|
||||
SetConversationModelInput,
|
||||
SetThinkingLevelInput,
|
||||
} from './contracts';
|
||||
import { CodingRuntimeContractError } from './runtime-errors';
|
||||
export { CodingRuntimeContractError } from './runtime-errors';
|
||||
import {
|
||||
createConversationReducerState,
|
||||
reduceConversationPatch,
|
||||
type ConversationReducerState,
|
||||
} from './conversation-reducer';
|
||||
|
||||
export class CodingRuntimeContractError extends Error {
|
||||
readonly publicError: CodingRuntimePublicError;
|
||||
|
||||
constructor(code: CodingRuntimeErrorCode, message: string, recoverable: boolean) {
|
||||
super(message);
|
||||
this.name = 'CodingRuntimeContractError';
|
||||
this.publicError = { code, message, recoverable };
|
||||
}
|
||||
}
|
||||
|
||||
export interface InMemoryConversationRuntimeOptions {
|
||||
snapshots?: ConversationSnapshot[];
|
||||
commands?: CodingRuntimeCommand[];
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
reduceConversationPatch,
|
||||
type ConversationReducerState,
|
||||
} from '../conversation-reducer';
|
||||
import { CodingRuntimeContractError } from '../in-memory-conversation-runtime';
|
||||
import { CodingRuntimeContractError } from '../runtime-errors';
|
||||
import {
|
||||
PiEventProjector,
|
||||
type PiEventProjectorOptions,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ConversationModelState,
|
||||
PrepareConversationInput,
|
||||
} from '../contracts';
|
||||
import { CodingRuntimeContractError } from '../runtime-errors';
|
||||
|
||||
export interface PiRegisteredConversation {
|
||||
projectPath: string;
|
||||
@@ -22,6 +23,7 @@ export interface PiRegisteredConversation {
|
||||
|
||||
export interface PiSessionRegistryOptions {
|
||||
projectStore: CodingProjectStore;
|
||||
createConversationStore?: typeof createCodingConversationStore;
|
||||
}
|
||||
|
||||
interface RegistryRecord extends PiRegisteredConversation {
|
||||
@@ -55,11 +57,13 @@ function sameModelState(left: ConversationModelState, right: ConversationModelSt
|
||||
|
||||
export class PiSessionRegistry {
|
||||
private readonly projectStore: CodingProjectStore;
|
||||
private readonly createConversationStore: typeof createCodingConversationStore;
|
||||
private readonly records = new Map<string, RegistryRecord>();
|
||||
private readonly prepareFlights = new Map<string, Promise<RegistryRecord>>();
|
||||
|
||||
constructor(options: PiSessionRegistryOptions) {
|
||||
this.projectStore = options.projectStore;
|
||||
this.createConversationStore = options.createConversationStore ?? createCodingConversationStore;
|
||||
}
|
||||
|
||||
async prepare(input: PrepareConversationInput): Promise<PiRegisteredConversation> {
|
||||
@@ -71,7 +75,9 @@ export class PiSessionRegistry {
|
||||
createBinding: () => Promise<PiSessionBinding>,
|
||||
): Promise<PiRegisteredConversation> {
|
||||
const record = await this.prepareRecord(input);
|
||||
const conversation = await record.store.ensureSessionBinding(input.conversationId, createBinding);
|
||||
const conversation = await this.persistWrite(
|
||||
() => record.store.ensureSessionBinding(input.conversationId, createBinding),
|
||||
);
|
||||
record.conversation = conversation;
|
||||
record.session = {
|
||||
piSessionId: conversation.piSessionId as string,
|
||||
@@ -86,7 +92,9 @@ export class PiSessionRegistry {
|
||||
): Promise<ConversationModelState> {
|
||||
const record = this.records.get(conversationId);
|
||||
if (!record) throw new Error('Conversation is not registered');
|
||||
record.conversation = await record.store.setModelState(conversationId, model);
|
||||
record.conversation = await this.persistWrite(
|
||||
() => record.store.setModelState(conversationId, model),
|
||||
);
|
||||
return modelStateOf(record.conversation);
|
||||
}
|
||||
|
||||
@@ -118,7 +126,7 @@ export class PiSessionRegistry {
|
||||
candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt
|
||||
));
|
||||
if (!agent) throw new Error('Coding Agent does not exist');
|
||||
const store = createCodingConversationStore(project.path);
|
||||
const store = this.createConversationStore(project.path);
|
||||
const conversation = await store.get(input.conversationId);
|
||||
if (!conversation || conversation.agentId !== input.agentId) {
|
||||
throw new Error('Coding Conversation does not exist for the selected Agent');
|
||||
@@ -138,4 +146,17 @@ export class PiSessionRegistry {
|
||||
this.records.set(input.conversationId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
private async persistWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof CodingRuntimeContractError) throw error;
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_STORAGE_WRITE_FAILED',
|
||||
'Coding Conversation state could not be persisted',
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
electron/coding-runtime/runtime-errors.ts
Normal file
14
electron/coding-runtime/runtime-errors.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {
|
||||
CodingRuntimeErrorCode,
|
||||
CodingRuntimePublicError,
|
||||
} from './contracts';
|
||||
|
||||
export class CodingRuntimeContractError extends Error {
|
||||
readonly publicError: CodingRuntimePublicError;
|
||||
|
||||
constructor(code: CodingRuntimeErrorCode, message: string, recoverable: boolean) {
|
||||
super(message);
|
||||
this.name = 'CodingRuntimeContractError';
|
||||
this.publicError = { code, message, recoverable };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user