feat: layer runtime and model selection across conversation, project and global
- Store runtime selection as { provider?, model? } layers and resolve them per
field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
purpose-specific models.
This commit is contained in:
@@ -2388,7 +2388,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('fails closed when an SSH project lacks a Runtime selection', async () => {
|
||||
it('lets an SSH project without a Runtime selection follow global settings', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-invalid-ssh-project-')
|
||||
)
|
||||
@@ -2411,9 +2411,8 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
raw.close()
|
||||
|
||||
expect(() => database.getProject(project.id)).toThrow(
|
||||
'缺少远程 Runtime 选择'
|
||||
)
|
||||
// Resolution limits remote projects to OpenCode or Continue at run time.
|
||||
expect(database.getProject(project.id).runtimeSelection).toBeUndefined()
|
||||
database.close()
|
||||
})
|
||||
|
||||
@@ -2478,12 +2477,6 @@ describe('AssistantDatabase', () => {
|
||||
database.createSshProject(rootMismatch)
|
||||
).toThrow('目录不匹配')
|
||||
|
||||
const missingRuntime = validatedSshProjectWrite()
|
||||
delete missingRuntime.project.runtimeSelection
|
||||
expect(() =>
|
||||
database.createSshProject(missingRuntime)
|
||||
).toThrow('必须选择 Runtime')
|
||||
|
||||
expect(() =>
|
||||
database.createSshProject(
|
||||
validatedSshProjectWrite({
|
||||
@@ -2727,10 +2720,8 @@ describe('AssistantDatabase', () => {
|
||||
rootPath: 'C:\\Users\\test'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
},
|
||||
// The model follows global settings instead of freezing today's default.
|
||||
runtimeSelection: { provider: 'model' },
|
||||
kind: 'channel',
|
||||
channel: 'weixin'
|
||||
}),
|
||||
@@ -2760,7 +2751,7 @@ describe('AssistantDatabase', () => {
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: '00000000-0000-4000-8000-000000000019'
|
||||
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000019' }
|
||||
}
|
||||
})
|
||||
expect(updated).toMatchObject({
|
||||
@@ -2774,7 +2765,7 @@ describe('AssistantDatabase', () => {
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: '00000000-0000-4000-8000-000000000019'
|
||||
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000019' }
|
||||
}
|
||||
})
|
||||
expect(() =>
|
||||
@@ -5159,7 +5150,7 @@ describe('AssistantDatabase', () => {
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: '00000000-0000-4000-8000-000000000299'
|
||||
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000299' }
|
||||
},
|
||||
knowledgeLibraryIds: [
|
||||
'00000000-0000-4000-8000-000000000214'
|
||||
@@ -5264,7 +5255,7 @@ describe('AssistantDatabase', () => {
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: '00000000-0000-4000-8000-000000000299'
|
||||
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000299' }
|
||||
},
|
||||
knowledgeLibraryIds: [
|
||||
'00000000-0000-4000-8000-000000000214'
|
||||
@@ -5654,7 +5645,7 @@ describe('AssistantDatabase', () => {
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
model: { kind: 'profile', profileId: channelDefaultProfileId }
|
||||
},
|
||||
knowledgeLibraryIds: [
|
||||
'00000000-0000-4000-8000-000000000559'
|
||||
@@ -5765,7 +5756,7 @@ describe('AssistantDatabase', () => {
|
||||
projectId: project.id,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
model: { kind: 'profile', profileId: channelDefaultProfileId }
|
||||
},
|
||||
knowledgeLibraryIds: [
|
||||
'00000000-0000-4000-8000-000000000559'
|
||||
@@ -6322,99 +6313,47 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('repairs unattended channel selections without rebinding ordinary conversations', async () => {
|
||||
it('clears deleted model connections from every project and conversation layer', async () => {
|
||||
const database = await createDatabase()
|
||||
const removedProfileId =
|
||||
'00000000-0000-4000-8000-000000000291'
|
||||
const defaultProfileId =
|
||||
'00000000-0000-4000-8000-000000000292'
|
||||
const runtimeProfileId =
|
||||
'00000000-0000-4000-8000-000000000293'
|
||||
const imageProfileId =
|
||||
'00000000-0000-4000-8000-000000000294'
|
||||
const removedProfileId = '00000000-0000-4000-8000-000000000291'
|
||||
const keptProfileId = '00000000-0000-4000-8000-000000000293'
|
||||
const imageProfileId = '00000000-0000-4000-8000-000000000294'
|
||||
database.replaceConversations(
|
||||
([
|
||||
['model', removedProfileId],
|
||||
['opencode', removedProfileId],
|
||||
['continue', removedProfileId],
|
||||
['model', runtimeProfileId]
|
||||
['continue', keptProfileId],
|
||||
[undefined, removedProfileId]
|
||||
] as const).map(([provider, profileId], index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000030${index}`,
|
||||
runtimeSelection: { provider, profileId },
|
||||
runtimeSelection: {
|
||||
...(provider ? { provider } : {}),
|
||||
model: { kind: 'profile' as const, profileId }
|
||||
},
|
||||
title: `对话 ${index}`,
|
||||
updatedAt: index + 1,
|
||||
messages: []
|
||||
}))
|
||||
)
|
||||
const channelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[0]!
|
||||
database.updateProject(channelProject.id, {
|
||||
name: channelProject.name,
|
||||
description: channelProject.description,
|
||||
rootPath: channelProject.rootPath,
|
||||
defaultWorkMode: channelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: runtimeProfileId
|
||||
}
|
||||
const [channelProject, imageChannelProject] = database.ensureChannelProjects('C:\\Users\\test')
|
||||
database.updateProject(channelProject!.id, {
|
||||
...channelProject!,
|
||||
runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: removedProfileId } }
|
||||
})
|
||||
const imageChannelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[1]!
|
||||
database.updateProject(imageChannelProject.id, {
|
||||
name: imageChannelProject.name,
|
||||
description: imageChannelProject.description,
|
||||
rootPath: imageChannelProject.rootPath,
|
||||
defaultWorkMode: imageChannelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: imageProfileId
|
||||
}
|
||||
database.updateProject(imageChannelProject!.id, {
|
||||
...imageChannelProject!,
|
||||
runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: imageProfileId } }
|
||||
})
|
||||
const automaticChannelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[2]!
|
||||
database.updateProject(automaticChannelProject.id, {
|
||||
name: automaticChannelProject.name,
|
||||
description: automaticChannelProject.description,
|
||||
rootPath: automaticChannelProject.rootPath,
|
||||
defaultWorkMode: automaticChannelProject.defaultWorkMode,
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
const localProject = database.createProject({
|
||||
name: '本地', description: '', rootPath: 'C:\\Local', defaultWorkMode: 'ask',
|
||||
runtimeSelection: { model: { kind: 'profile', profileId: removedProfileId } }
|
||||
})
|
||||
const automaticRemoteConversation =
|
||||
database.getOrCreateRemoteConversation({
|
||||
projectId: automaticChannelProject.id,
|
||||
channel: 'dingtalk',
|
||||
accountId: 'default',
|
||||
externalConversationId: 'legacy-auto-conversation',
|
||||
conversationType: 'direct',
|
||||
title: '钉钉 · 旧版自动后端',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
|
||||
expect(
|
||||
database.repairConversationRuntimeSelections({
|
||||
modelProfiles: [
|
||||
{ id: defaultProfileId },
|
||||
{ id: runtimeProfileId },
|
||||
{
|
||||
id: imageProfileId,
|
||||
protocol: 'openai-images-generations'
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: defaultProfileId,
|
||||
opencodeModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: runtimeProfileId
|
||||
},
|
||||
continueModelSource: { kind: 'platform' }
|
||||
modelProfiles: [{ id: keptProfileId }, { id: imageProfileId, protocol: 'openai-images-generations' }]
|
||||
})
|
||||
).toBe(3)
|
||||
).toBe(5)
|
||||
expect(
|
||||
database
|
||||
.listConversations()
|
||||
@@ -6422,35 +6361,49 @@ describe('AssistantDatabase', () => {
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.map((conversation) => conversation.runtimeSelection)
|
||||
).toEqual([
|
||||
{ provider: 'model', profileId: removedProfileId },
|
||||
{ provider: 'opencode', profileId: removedProfileId },
|
||||
{ provider: 'continue', profileId: removedProfileId },
|
||||
{ provider: 'model', profileId: runtimeProfileId }
|
||||
{ provider: 'model' },
|
||||
{ provider: 'opencode' },
|
||||
{ provider: 'continue', model: { kind: 'profile', profileId: keptProfileId } },
|
||||
undefined
|
||||
])
|
||||
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
||||
provider: 'opencode',
|
||||
profileId: runtimeProfileId
|
||||
})
|
||||
expect(
|
||||
database.getProject(imageChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model'
|
||||
})
|
||||
expect(
|
||||
database.getProject(automaticChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model'
|
||||
})
|
||||
expect(
|
||||
database.getConversation(
|
||||
automaticRemoteConversation.id
|
||||
).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model'
|
||||
expect(database.getProject(channelProject!.id).runtimeSelection).toEqual({ provider: 'opencode' })
|
||||
// An existing but incompatible model is kept so the UI can explain the fallback.
|
||||
expect(database.getProject(imageChannelProject!.id).runtimeSelection).toEqual({
|
||||
provider: 'model', model: { kind: 'profile', profileId: imageProfileId }
|
||||
})
|
||||
expect(database.getProject(localProject.id).runtimeSelection).toBeUndefined()
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates legacy stored selections to layers', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-runtime-layer-migration-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
const project = database.listProjects()[0]!
|
||||
database.close()
|
||||
const raw = new DatabaseSync(databasePath)
|
||||
raw.prepare('UPDATE projects SET runtime_selection_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify({ provider: 'continue', profileId: '00000000-0000-4000-8000-000000000301' }), project.id)
|
||||
raw.prepare(
|
||||
`INSERT INTO conversations (id, project_id, runtime_selection_json, work_mode, title, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'ask', 'legacy', 'active', ?, ?)`
|
||||
).run('00000000-0000-4000-8000-000000000302', project.id, JSON.stringify({ provider: 'auto' }), '2026-01-01', '2026-01-01')
|
||||
raw.exec('PRAGMA user_version = 48')
|
||||
raw.close()
|
||||
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
const check = new DatabaseSync(databasePath)
|
||||
expect(check.prepare('SELECT runtime_selection_json AS value FROM projects WHERE id = ?').get(project.id))
|
||||
.toEqual({ value: JSON.stringify({ provider: 'continue', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000301' } }) })
|
||||
expect(check.prepare('SELECT runtime_selection_json AS value FROM conversations WHERE id = ?').get('00000000-0000-4000-8000-000000000302'))
|
||||
.toEqual({ value: null })
|
||||
check.close()
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('durably interrupts active tool and subagent metadata during startup recovery', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-conversation-recovery-')
|
||||
|
||||
@@ -83,10 +83,14 @@ import {
|
||||
} from '../../shared/channel-contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
agentRuntimeSelectionSchema,
|
||||
repairChannelRuntimeSelection,
|
||||
compactRuntimeSelectionLayer,
|
||||
optionalAgentRuntimeSelectionSchema,
|
||||
repairRuntimeSelectionLayer,
|
||||
runtimeSelectionLayerSchema,
|
||||
withoutLegacyAutoSelection,
|
||||
type AgentRuntimeSelection,
|
||||
type RuntimeSelectionRepairSettings
|
||||
type RuntimeResolutionSettings,
|
||||
type RuntimeSelectionLayer
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import {
|
||||
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES,
|
||||
@@ -126,7 +130,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 48
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 49
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -318,22 +322,69 @@ type MessageMetadata = {
|
||||
const MAX_CHANNEL_OUTBOX_RETRY_BYTES = 20 * 1024 * 1024
|
||||
const MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES = 8
|
||||
|
||||
function parseRuntimeSelection(value: string | null):
|
||||
| ConversationSnapshot['runtimeSelection']
|
||||
| undefined {
|
||||
/** Rewrites pre-layer `{ provider, profileId }` and `{ provider: 'auto' }` rows. */
|
||||
function migrateRuntimeSelectionLayers(database: DatabaseSync): void {
|
||||
for (const table of ['projects', 'conversations'] as const) {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json FROM ${table}
|
||||
WHERE runtime_selection_json IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{ id: string; runtime_selection_json: string }>
|
||||
const update = database.prepare(
|
||||
`UPDATE ${table} SET runtime_selection_json = ? WHERE id = ?`
|
||||
)
|
||||
for (const row of rows) {
|
||||
const next = serializeRuntimeSelection(
|
||||
parseRuntimeSelection(row.runtime_selection_json)
|
||||
)
|
||||
if (next !== row.runtime_selection_json) update.run(next, row.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a stored layer; legacy concrete and `auto` selections upgrade on read. */
|
||||
function parseRuntimeSelection(
|
||||
value: string | null
|
||||
): RuntimeSelectionLayer | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = agentRuntimeSelectionSchema.safeParse(
|
||||
JSON.parse(value)
|
||||
)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
const parsed = runtimeSelectionLayerSchema.safeParse(JSON.parse(value))
|
||||
return parsed.success ? compactRuntimeSelectionLayer(parsed.data) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRuntimeSelection(
|
||||
layer: RuntimeSelectionLayer | undefined
|
||||
): string | null {
|
||||
// Normalize legacy `{ provider, profileId }` input before storing it.
|
||||
const parsed = runtimeSelectionLayerSchema.safeParse(layer)
|
||||
const compact = parsed.success ? compactRuntimeSelectionLayer(parsed.data) : undefined
|
||||
return compact ? JSON.stringify(compact) : null
|
||||
}
|
||||
|
||||
/** Remote events record the concrete selection they ran with for context metrics keys. */
|
||||
function remoteEventSelectionKey(
|
||||
requestSelection: AgentRuntimeSelection | undefined,
|
||||
conversationLayer: RuntimeSelectionLayer | undefined,
|
||||
projectLayer: RuntimeSelectionLayer | undefined
|
||||
): string {
|
||||
if (requestSelection) return agentRuntimeSelectionKey(requestSelection)
|
||||
const provider = conversationLayer?.provider ?? projectLayer?.provider ?? 'opencode'
|
||||
const model = conversationLayer?.model ?? projectLayer?.model
|
||||
return agentRuntimeSelectionKey(
|
||||
model?.kind === 'profile'
|
||||
? { provider, profileId: model.profileId }
|
||||
: model?.kind === 'runtime-config' && provider !== 'model'
|
||||
? { provider, runtimeConfig: true }
|
||||
: { provider }
|
||||
)
|
||||
}
|
||||
|
||||
type ArtifactRow = {
|
||||
id: string
|
||||
project_id: string | null
|
||||
@@ -576,18 +627,7 @@ function toProject(row: ProjectRow): AssistantProject {
|
||||
if (!executionSpace.success) {
|
||||
throw new Error(`项目 ${row.id} 的执行空间配置无效`)
|
||||
}
|
||||
const runtimeSelection =
|
||||
row.kind === 'channel'
|
||||
? parseRuntimeSelection(row.runtime_selection_json) ?? {
|
||||
provider: 'auto' as const
|
||||
}
|
||||
: parseRuntimeSelection(row.runtime_selection_json)
|
||||
if (
|
||||
executionSpace.data.kind === 'ssh' &&
|
||||
runtimeSelection === undefined
|
||||
) {
|
||||
throw new Error(`项目 ${row.id} 缺少远程 Runtime 选择`)
|
||||
}
|
||||
const runtimeSelection = parseRuntimeSelection(row.runtime_selection_json)
|
||||
const rootPath =
|
||||
executionSpace.data.kind === 'local'
|
||||
? executionSpace.data.rootPath
|
||||
@@ -764,8 +804,8 @@ function toSchedule(row: ScheduleWithTaskRow): AssistantSchedule {
|
||||
workMode: LegacyWorkMode
|
||||
runtimeSelection?: unknown
|
||||
}
|
||||
const runtimeSelection = agentRuntimeSelectionSchema.safeParse(
|
||||
template.runtimeSelection
|
||||
const runtimeSelection = optionalAgentRuntimeSelectionSchema.safeParse(
|
||||
withoutLegacyAutoSelection(template.runtimeSelection)
|
||||
)
|
||||
const recurrence = JSON.parse(row.recurrence_json) as {
|
||||
type: AssistantSchedule['recurrence']
|
||||
@@ -1666,9 +1706,6 @@ function normalizeSshProjectWrite(
|
||||
if (project.rootPath !== executionSpace.remoteRootPath) {
|
||||
throw new Error('项目目录与远程执行空间目录不匹配')
|
||||
}
|
||||
if (project.runtimeSelection === undefined) {
|
||||
throw new Error('远程项目必须选择 Runtime')
|
||||
}
|
||||
if (typeof write.assertCurrent !== 'function') {
|
||||
throw new TypeError('缺少 SSH Host 事务前置校验')
|
||||
}
|
||||
@@ -2334,8 +2371,10 @@ export class AssistantDatabase {
|
||||
|
||||
ensureChannelProjects(
|
||||
defaultRootPath: string,
|
||||
defaultModelProfileId: string
|
||||
_defaultModelProfileId?: string
|
||||
): AssistantProject[] {
|
||||
// Kept for callers; channel projects no longer pin the current default model.
|
||||
void _defaultModelProfileId
|
||||
const database = this.requireDatabase()
|
||||
const definitions: ReadonlyArray<{
|
||||
channel: ProjectChannel
|
||||
@@ -2387,10 +2426,8 @@ export class AssistantDatabase {
|
||||
definition.name,
|
||||
definition.description,
|
||||
defaultRootPath,
|
||||
JSON.stringify({
|
||||
provider: 'model',
|
||||
profileId: defaultModelProfileId
|
||||
}),
|
||||
// Unattended channels start on the direct model; the model follows global settings.
|
||||
JSON.stringify({ provider: 'model' }),
|
||||
definition.channel,
|
||||
now,
|
||||
now
|
||||
@@ -2441,9 +2478,7 @@ export class AssistantDatabase {
|
||||
input.description,
|
||||
input.rootPath,
|
||||
input.defaultWorkMode,
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(input.runtimeSelection),
|
||||
builtInDefault ? 1 : 0,
|
||||
now,
|
||||
now
|
||||
@@ -2493,11 +2528,8 @@ export class AssistantDatabase {
|
||||
input.description,
|
||||
input.rootPath,
|
||||
input.defaultWorkMode,
|
||||
input.runtimeSelection || current.runtimeSelection
|
||||
? JSON.stringify(
|
||||
input.runtimeSelection ?? current.runtimeSelection
|
||||
)
|
||||
: null,
|
||||
// The settings form always sends the full layer; absent means follow global.
|
||||
serializeRuntimeSelection(input.runtimeSelection),
|
||||
new Date().toISOString(),
|
||||
projectId
|
||||
)
|
||||
@@ -2547,9 +2579,7 @@ export class AssistantDatabase {
|
||||
normalized.project.description,
|
||||
normalized.executionSpace.remoteRootPath,
|
||||
normalized.project.defaultWorkMode,
|
||||
normalized.project.runtimeSelection
|
||||
? JSON.stringify(normalized.project.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(normalized.project.runtimeSelection),
|
||||
now,
|
||||
now
|
||||
)
|
||||
@@ -2640,9 +2670,7 @@ export class AssistantDatabase {
|
||||
normalized.project.description,
|
||||
normalized.executionSpace.remoteRootPath,
|
||||
normalized.project.defaultWorkMode,
|
||||
normalized.project.runtimeSelection
|
||||
? JSON.stringify(normalized.project.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(normalized.project.runtimeSelection),
|
||||
updatedAt,
|
||||
projectId,
|
||||
expectedUpdatedAt
|
||||
@@ -2988,32 +3016,29 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears references to deleted model connections in every project and
|
||||
* conversation so they fall back to the next layer instead of failing.
|
||||
* Also rewrites legacy stored shapes to the layered form.
|
||||
*/
|
||||
repairConversationRuntimeSelections(
|
||||
settings: RuntimeSelectionRepairSettings
|
||||
settings: Pick<RuntimeResolutionSettings, 'modelProfiles'>
|
||||
): number {
|
||||
const database = this.requireDatabase()
|
||||
const projects = database
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json
|
||||
FROM projects
|
||||
WHERE kind = 'channel'`
|
||||
WHERE runtime_selection_json IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string
|
||||
runtime_selection_json: string | null
|
||||
}>
|
||||
.all() as Array<{ id: string; runtime_selection_json: string }>
|
||||
const conversations = database
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json, channel
|
||||
`SELECT id, runtime_selection_json
|
||||
FROM conversations
|
||||
WHERE runtime_selection_json IS NOT NULL
|
||||
AND channel IS NOT NULL`
|
||||
WHERE runtime_selection_json IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string
|
||||
runtime_selection_json: string
|
||||
channel: ProjectChannel | null
|
||||
}>
|
||||
.all() as Array<{ id: string; runtime_selection_json: string }>
|
||||
const update = database.prepare(
|
||||
`UPDATE conversations
|
||||
SET runtime_selection_json = ?
|
||||
@@ -3024,44 +3049,25 @@ export class AssistantDatabase {
|
||||
SET runtime_selection_json = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
const repairedJson = (stored: string): string | null | false => {
|
||||
const next = serializeRuntimeSelection(
|
||||
repairRuntimeSelectionLayer(parseRuntimeSelection(stored), settings)
|
||||
)
|
||||
return next === stored ? false : next
|
||||
}
|
||||
let repaired = 0
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const project of projects) {
|
||||
const stored = parseRuntimeSelection(
|
||||
project.runtime_selection_json
|
||||
)
|
||||
const current = stored ?? { provider: 'auto' as const }
|
||||
const next = repairChannelRuntimeSelection(current, settings)
|
||||
if (
|
||||
stored &&
|
||||
agentRuntimeSelectionKey(next) ===
|
||||
agentRuntimeSelectionKey(current)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
updateProject.run(
|
||||
JSON.stringify(next),
|
||||
new Date().toISOString(),
|
||||
project.id
|
||||
)
|
||||
const next = repairedJson(project.runtime_selection_json)
|
||||
if (next === false) continue
|
||||
updateProject.run(next, new Date().toISOString(), project.id)
|
||||
repaired += 1
|
||||
}
|
||||
for (const conversation of conversations) {
|
||||
const current = parseRuntimeSelection(
|
||||
conversation.runtime_selection_json
|
||||
)
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const next = repairChannelRuntimeSelection(current, settings)
|
||||
if (
|
||||
agentRuntimeSelectionKey(next) ===
|
||||
agentRuntimeSelectionKey(current)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
update.run(JSON.stringify(next), conversation.id)
|
||||
const next = repairedJson(conversation.runtime_selection_json)
|
||||
if (next === false) continue
|
||||
update.run(next, conversation.id)
|
||||
repaired += 1
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
@@ -3107,9 +3113,7 @@ export class AssistantDatabase {
|
||||
insertConversation.run(
|
||||
conversation.id,
|
||||
conversation.projectId ?? null,
|
||||
conversation.runtimeSelection
|
||||
? JSON.stringify(conversation.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(conversation.runtimeSelection),
|
||||
conversation.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(conversation),
|
||||
conversation.title,
|
||||
@@ -3202,9 +3206,7 @@ export class AssistantDatabase {
|
||||
if (existingConversation) {
|
||||
const result = updateConversation.run(
|
||||
header.projectId ?? null,
|
||||
header.runtimeSelection
|
||||
? JSON.stringify(header.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(header.runtimeSelection),
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
@@ -3220,9 +3222,7 @@ export class AssistantDatabase {
|
||||
insertConversation.run(
|
||||
header.id,
|
||||
header.projectId ?? null,
|
||||
header.runtimeSelection
|
||||
? JSON.stringify(header.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(header.runtimeSelection),
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
@@ -3525,7 +3525,7 @@ export class AssistantDatabase {
|
||||
conversationType: 'direct' | 'group'
|
||||
title: string
|
||||
accountDisplay: string
|
||||
runtimeSelection?: AgentRuntimeSelection
|
||||
runtimeSelection?: RuntimeSelectionLayer
|
||||
}): ConversationSnapshot {
|
||||
const database = this.requireDatabase()
|
||||
const existing = database
|
||||
@@ -3556,9 +3556,7 @@ export class AssistantDatabase {
|
||||
input.title,
|
||||
input.conversationType,
|
||||
input.accountDisplay,
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(input.runtimeSelection),
|
||||
new Date().toISOString(),
|
||||
existing.id
|
||||
)
|
||||
@@ -3578,9 +3576,7 @@ export class AssistantDatabase {
|
||||
.run(
|
||||
id,
|
||||
input.projectId,
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
serializeRuntimeSelection(input.runtimeSelection),
|
||||
input.title,
|
||||
input.channel,
|
||||
input.accountId,
|
||||
@@ -5873,9 +5869,7 @@ export class AssistantDatabase {
|
||||
input.assistantMessageId
|
||||
)
|
||||
const requestRuntimeSelection =
|
||||
input.runtimeSelection === undefined
|
||||
? undefined
|
||||
: agentRuntimeSelectionSchema.parse(input.runtimeSelection)
|
||||
optionalAgentRuntimeSelectionSchema.parse(input.runtimeSelection)
|
||||
const publicEvent = stripRemoteEventProvenance(input.event)
|
||||
if (publicEvent.requestId !== input.taskId) {
|
||||
throw new Error('远程事件的请求 ID 与任务不匹配')
|
||||
@@ -6012,17 +6006,16 @@ export class AssistantDatabase {
|
||||
let contextState = parseConversationContextState(
|
||||
conversation.context_state_json
|
||||
)
|
||||
const runtimeSelection =
|
||||
requestRuntimeSelection ??
|
||||
parseRuntimeSelection(conversation.runtime_selection_json) ??
|
||||
parseRuntimeSelection(conversation.project_runtime_selection_json) ??
|
||||
{ provider: 'auto' as const }
|
||||
const runtimeSelectionKey = remoteEventSelectionKey(
|
||||
requestRuntimeSelection,
|
||||
parseRuntimeSelection(conversation.runtime_selection_json),
|
||||
parseRuntimeSelection(conversation.project_runtime_selection_json)
|
||||
)
|
||||
if (publicEvent.type === 'context-metrics') {
|
||||
contextState = {
|
||||
...contextState,
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey:
|
||||
agentRuntimeSelectionKey(runtimeSelection),
|
||||
runtimeSelectionKey,
|
||||
contextTokens: publicEvent.contextTokens,
|
||||
source: publicEvent.source,
|
||||
basis: 'model-call'
|
||||
@@ -6046,8 +6039,7 @@ export class AssistantDatabase {
|
||||
estimatedAfterTokens !== undefined
|
||||
? {
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey:
|
||||
agentRuntimeSelectionKey(runtimeSelection),
|
||||
runtimeSelectionKey,
|
||||
contextTokens: estimatedAfterTokens,
|
||||
source: 'estimated' as const,
|
||||
basis: 'conversation' as const
|
||||
@@ -11569,6 +11561,13 @@ export class AssistantDatabase {
|
||||
database.exec('PRAGMA user_version = 48; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 49) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
migrateRuntimeSelectionLayers(database)
|
||||
database.exec('PRAGMA user_version = 49; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionReviewStore(): SupervisionReviewStore {
|
||||
|
||||
Reference in New Issue
Block a user