From 7d59857e93ef58c523bd9f4db0959dfc9710ba0f Mon Sep 17 00:00:00 2001 From: HugoXK Date: Thu, 5 Nov 2026 16:12:01 +0800 Subject: [PATCH] feat: assign models to individual bots Add per-role model selection for Chief/Lead/Product/Test/Security/Reviewer/Dev-1/Dev-2, persist it in repo_bot_models, and apply it through DSH update-bot after a model restart. --- changelog.d/bot-models.added.md | 1 + docs/architecture.md | 4 ++- src/app.ts | 5 ++++ src/db.ts | 37 ++++++++++++++++++++++++--- src/flybot/instances.ts | 39 ++++++++++++++++++++++++++++- src/views/pages.ts | 6 +++-- test/db.test.ts | 22 ++++++++++++++++- test/db.test.ts.bak | 44 ++++++++++++++++++++++++++++++++- test/instances.test.ts | 41 ++++++++++++++++++++++++++++++ 9 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 changelog.d/bot-models.added.md diff --git a/changelog.d/bot-models.added.md b/changelog.d/bot-models.added.md new file mode 100644 index 0000000..652c8e7 --- /dev/null +++ b/changelog.d/bot-models.added.md @@ -0,0 +1 @@ +A repository can assign a selected provider model to each bot role (Chief, Lead, Product, Test, Security, Reviewer, Dev-1, Dev-2). Empty roles follow the default model. Applying models restarts the workspace and Fledge assigns the model through DSH `update-bot`. diff --git a/docs/architecture.md b/docs/architecture.md index 0f978bb..e2bcdd6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -321,7 +321,9 @@ which is default (`repo_model_selection`). Adding a model runs a 1-token test from Fledge and is refused on failure. The instance patch groups the selected models under their providers as DSH models, and the gateway maps an alias back to its provider, key and underlying model. Applying a changed selection rewrites -the patch and restarts the container only when no job is active. +the patch and restarts the container only when no job is active. A repository can +also assign a selected model to each DSH bot role; after the restart Fledge calls +`update-bot` with `model: /` for every configured role. ### Per-job cost, evidence and metrics diff --git a/src/app.ts b/src/app.ts index ef15b05..0b79342 100644 --- a/src/app.ts +++ b/src/app.ts @@ -899,6 +899,7 @@ export async function buildApp(options: BuildOptions): Promise { workItems: store.listWorkItems(repo.id), models: store.listUserProviderModels(repo.user_id), repoModelIds: repoModels.map((model) => model.id), + repoBotModels: store.listRepoBotModels(repo.id), repoDefaultModelId: repoModels.find((model) => model.is_default === 1)?.id ?? '', repoSpendCny: store.repoUsageSince(repo.id, new Date(Date.now() - 30 * 86_400_000).toISOString()) * config.quota.usdCny, questions, @@ -1685,6 +1686,10 @@ export async function buildApp(options: BuildOptions): Promise { const requestedModels = [...new Set(formList(request, 'modelIds').filter((id) => allowedModels.has(id)))] const requestedDefault = (body.defaultModelId ?? '').trim() const effectiveDefault = requestedDefault !== '' && requestedModels.includes(requestedDefault) ? requestedDefault : requestedModels[0] ?? '' + for (const role of ['chief', 'lead', 'product', 'test', 'security', 'reviewer', 'dev1', 'dev2']) { + const value = (body[`botModel_${role}`] ?? '').trim() + store.setRepoBotModel(repo.id, role, requestedModels.includes(value) ? value : null) + } const currentModels = store.listRepoModelSelection(repo.id) const currentDefault = currentModels.find((model) => model.is_default === 1)?.id ?? '' const currentIds = currentModels.map((model) => model.id).sort().join(',') diff --git a/src/db.ts b/src/db.ts index a07fec2..fa2efdb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -160,6 +160,13 @@ export interface RepoProviderModelRow extends ProviderModelRow { is_default: number } +export interface RepoBotModelRow { + repo_id: string + role: string + provider_model_id: string + created_at: string +} + export interface RepoInstanceRow { repo_id: string user_id: string @@ -620,6 +627,14 @@ CREATE TABLE IF NOT EXISTS repo_model_selection ( PRIMARY KEY (repo_id, provider_model_id) ); +CREATE TABLE IF NOT EXISTS repo_bot_models ( + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + role TEXT NOT NULL, + provider_model_id TEXT NOT NULL REFERENCES provider_models(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (repo_id, role) +); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -650,9 +665,9 @@ export class Store { this.db.exec('PRAGMA foreign_keys = ON;') this.db.exec('PRAGMA busy_timeout = 5000;') const previousVersion = Number(this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0) - if (previousVersion > 25) { + if (previousVersion > 26) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (25)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (26)`) } this.db.exec(SCHEMA) this.migrateColumns() @@ -666,7 +681,7 @@ export class Store { if (previousVersion < 16) { this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso()) } - this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 25)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 26)};`) } backfillTokenRepoIds(): void { @@ -1231,10 +1246,26 @@ export class Store { for (const modelId of unique) { this.run('INSERT INTO repo_model_selection (repo_id, provider_model_id, is_default, created_at) VALUES (?, ?, ?, ?)', repoId, modelId, modelId === chosen ? 1 : 0, nowIso()) } + this.run('DELETE FROM repo_bot_models WHERE repo_id = ? AND provider_model_id NOT IN (SELECT provider_model_id FROM repo_model_selection WHERE repo_id = ?)', repoId, repoId) this.run('UPDATE repos SET models_dirty = 1 WHERE id = ?', repoId) }) } + listRepoBotModels(repoId: string): RepoBotModelRow[] { + return this.all('SELECT * FROM repo_bot_models WHERE repo_id = ? ORDER BY role ASC', repoId) + } + + setRepoBotModel(repoId: string, role: string, providerModelId: string | null): void { + if (providerModelId === null || providerModelId === '') { + this.run('DELETE FROM repo_bot_models WHERE repo_id = ? AND role = ?', repoId, role) + return + } + this.run( + 'INSERT INTO repo_bot_models (repo_id, role, provider_model_id, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(repo_id, role) DO UPDATE SET provider_model_id = excluded.provider_model_id, created_at = excluded.created_at', + repoId, role, providerModelId, nowIso(), + ) + } + createSession(input: { idHash: string; userId: string; expiresAt: string; ip: string | null; userAgent: string | null }): void { this.run( 'INSERT INTO sessions (id_hash, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)', diff --git a/src/flybot/instances.ts b/src/flybot/instances.ts index c95d4e4..d2921e6 100644 --- a/src/flybot/instances.ts +++ b/src/flybot/instances.ts @@ -402,6 +402,42 @@ server.listen(3081, '0.0.0.0') return lines.join('\n') + '\n' } + async applyBotModels(repoId: string): Promise<{ updated: number; skipped: string[] }> { + const instance = this.store.getRepoInstance(repoId) + if (instance === undefined || instance.status !== 'ready') return { updated: 0, skipped: [] } + const mappings = this.store.listRepoBotModels(repoId) + if (mappings.length === 0) return { updated: 0, skipped: [] } + const selected = new Map(this.store.listRepoModelSelection(repoId).map((model) => [model.id, model])) + const token = decryptSecret(instance.bridge_token_enc ?? '', this.config.tokenEncryptionKey) + const state = await this.state(instance.bridge_port, token) + const suffix: Record = { lead: 'Lead', product: 'Product', test: 'Test', security: 'Security', reviewer: 'Reviewer', dev1: 'Dev-1', dev2: 'Dev-2' } + let updated = 0 + const skipped: string[] = [] + for (const mapping of mappings) { + const model = selected.get(mapping.provider_model_id) + if (model === undefined) { + skipped.push(mapping.role) + continue + } + const bot = mapping.role === 'chief' + ? state.bots.find((entry) => String(entry.name ?? '') === 'Chief') + : state.bots.find((entry) => String(entry.name ?? '').endsWith(` ${suffix[mapping.role] ?? ''}`)) + if (bot === undefined || typeof bot.id !== 'string') { + skipped.push(mapping.role) + continue + } + try { + await this.dsh(instance.bridge_port, token, 'update-bot', { id: bot.id, model: `fledge-${model.provider_id}/${model.id}` }) + updated += 1 + } catch (error) { + skipped.push(mapping.role) + log.warn('bot model update failed', { repo: repoId, role: mapping.role, error: String(error) }) + } + } + this.store.audit(instance.user_id, 'flybot.bot_models.applied', `${repoId} updated=${updated} skipped=${skipped.join(',')}`) + return { updated, skipped } + } + async applyModels(repoId: string): Promise<{ ok: boolean; code?: string; error?: string }> { const instance = this.store.getRepoInstance(repoId) if (instance === undefined || instance.status !== 'ready') return { ok: false, code: 'INSTANCE_NOT_READY', error: '实例尚未就绪。' } @@ -414,7 +450,8 @@ server.listen(3081, '0.0.0.0') await this.docker(['restart', '-t', '15', instance.container]) await this.waitForDsh(instance.dsh_port, instance) this.store.markRepoModelsApplied(repoId) - this.store.audit(instance.user_id, 'flybot.models.applied', `${repoId} models=${this.store.listRepoModels(repoId).length}`) + await this.applyBotModels(repoId).catch((error) => log.warn('bot model assignment failed', { repo: repoId, error: String(error) })) + this.store.audit(instance.user_id, 'flybot.models.applied', `${repoId} models=${this.store.listRepoModelSelection(repoId).length}`) log.info('models applied', { repo: repoId }) return { ok: true } } diff --git a/src/views/pages.ts b/src/views/pages.ts index 784ea9b..abfdc5e 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -460,13 +460,14 @@ export function dashboardPage(input: { return layout({ title: t('仓库', 'Repositories'), viewer, body, config, locale, path: '/dashboard', active: 'dashboard', notice, error }) } -export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; prJobs?: JobRow[]; workItems?: WorkItemRow[]; models?: Array; repoModelIds?: string[]; repoDefaultModelId?: string; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string { +export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; prJobs?: JobRow[]; workItems?: WorkItemRow[]; models?: Array; repoModelIds?: string[]; repoDefaultModelId?: string; repoBotModels?: Array<{ role: string; provider_model_id: string }>; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string { const { config, locale, viewer, repo, instance, jobs, notice, error } = input const workItems = input.workItems ?? [] const prJobs = input.prJobs ?? [] const models = input.models ?? [] const repoModelIds = new Set(input.repoModelIds ?? []) const repoDefaultModelId = input.repoDefaultModelId ?? '' + const botModelMap = new Map((input.repoBotModels ?? []).map((entry) => [entry.role, entry.provider_model_id])) const repoSpendCny = input.repoSpendCny ?? 0 const questions = input.questions ?? [] const chiefMessages = input.chiefMessages ?? [] @@ -741,7 +742,8 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer -

${escapeHtml(t('保存选择后,空闲时点击“应用模型并重启工作区”才会生效。', 'After saving the selection, use "Apply models and restart workspace" while idle for it to take effect.'))}

+ ${models.length === 0 ? '' : `
${([['chief', 'Chief'], ['lead', 'Lead'], ['product', 'Product'], ['test', 'Test'], ['security', 'Security'], ['reviewer', 'Reviewer'], ['dev1', 'Dev-1'], ['dev2', 'Dev-2']] as const).map(([role, label]) => ``).join('')}
+

${escapeHtml(t('每个 bot 可以单独选择模型;不选则跟随默认。保存选择后,空闲时点击“应用模型并重启工作区”才会生效,Fledge 会通过 DSH 把模型分配给对应 bot。', 'Each bot can choose a model; empty follows the default. After saving, use "Apply models and restart workspace" while idle; Fledge assigns the model through DSH.'))}

`} ${roleMissing ? `

${escapeHtml(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.'))}

` : !githubCanReview ? `

${escapeHtml(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.'))}

` : !canMaintain ? `

${escapeHtml(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.'))}

` : ''} ${advanced ? `
${escapeHtml(t('团队与调度规则', 'Team and scheduling rules'))}

${escapeHtml(t('保存目标后,团队 playbook 会按新目标更新:总控 → 负责人 → 产品/测试/安全/审核/开发。审核角色只有勾选“启用 PR 审核”后才会参与。', 'Saving the goal updates the team playbook: Chief -> Lead -> Product/Test/Security/Reviewer/Dev. The Reviewer takes part only when "Enable PR reviewer" is checked.'))}

diff --git a/test/db.test.ts b/test/db.test.ts index 17f24ba..12f0910 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -150,7 +150,7 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - reopened.db.exec('PRAGMA user_version = 26;') + reopened.db.exec('PRAGMA user_version = 27;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) @@ -351,3 +351,23 @@ test('legacy user models and own model migrate to providers', () => { store.close() } }) + +test('repository bot model assignments persist and are cleaned with the selection', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'bot-model-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_bot_models', userId: user.id, owner: 'o', name: 'b', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const provider = store.createModelProvider({ id: 'prv_bot', userId: user.id, label: 'Provider', baseUrl: 'https://p.example/v1', keyEnc: 'enc', allowPrivate: false }) + const first = store.createProviderModel({ id: 'pvm_bot_a', providerId: provider.id, model: 'a', label: 'A' }) + const second = store.createProviderModel({ id: 'pvm_bot_b', providerId: provider.id, model: 'b', label: 'B' }) + store.setRepoModelSelection(repo.id, [first.id, second.id], first.id) + store.setRepoBotModel(repo.id, 'lead', first.id) + store.setRepoBotModel(repo.id, 'product', second.id) + assert.equal(store.listRepoBotModels(repo.id).length, 2) + store.setRepoModelSelection(repo.id, [second.id], second.id) + const remaining = store.listRepoBotModels(repo.id) + assert.deepEqual(remaining.map((entry) => entry.role), ['product']) + } finally { + store.close() + } +}) diff --git a/test/db.test.ts.bak b/test/db.test.ts.bak index 4c02ef4..17f24ba 100644 --- a/test/db.test.ts.bak +++ b/test/db.test.ts.bak @@ -150,7 +150,7 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - reopened.db.exec('PRAGMA user_version = 25;') + reopened.db.exec('PRAGMA user_version = 26;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) @@ -309,3 +309,45 @@ test('existing instance and job tokens are backfilled with their repository', () store.close() } }) + +test('model providers and provider models can be selected per repository', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'provider-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_provider', userId: user.id, owner: 'o', name: 'p', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const provider = store.createModelProvider({ id: 'prv_a', userId: user.id, label: 'Provider A', baseUrl: 'https://a.example/v1', keyEnc: 'enc', allowPrivate: false }) + const first = store.createProviderModel({ id: 'pvm_a1', providerId: provider.id, model: 'model-a', label: 'A' }) + const second = store.createProviderModel({ id: 'pvm_a2', providerId: provider.id, model: 'model-b', label: 'B' }) + assert.equal(store.listModelProviders(user.id).length, 1) + assert.equal(store.listProviderModels(provider.id).length, 2) + store.setRepoModelSelection(repo.id, [first.id, second.id], second.id) + const selected = store.listRepoModelSelection(repo.id) + assert.deepEqual(selected.map((model) => model.id), [first.id, second.id]) + assert.equal(selected.find((model) => model.is_default === 1)?.id, second.id) + assert.equal(store.providerModelForRun(repo.id, second.id)?.model, 'model-b') + assert.equal(store.deleteProviderModel(first.id, user.id), true) + assert.equal(store.listRepoModelSelection(repo.id).length, 1) + } finally { + store.close() + } +}) + +test('legacy user models and own model migrate to providers', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'legacy-model-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_legacy_models', userId: user.id, owner: 'o', name: 'l', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const legacy = store.createUserModel({ id: 'mdl_legacy', userId: user.id, label: 'Legacy', baseUrl: 'https://legacy.example/v1', keyEnc: 'enc', model: 'legacy-model', allowPrivate: false }) + store.setRepoModels(repo.id, [legacy.id], legacy.id) + store.setUserOwnModel(user.id, 'https://own.example/v1', 'own-enc', 'own-model') + store.deleteSetting('models_v2') + store.migrateModelsV2() + assert.equal(store.listModelProviders(user.id).length, 2) + assert.equal(store.listRepoModelSelection(repo.id).length, 1) + const labels = store.listUserProviderModels(user.id).map((model) => model.model) + assert.ok(labels.includes('legacy-model')) + assert.ok(labels.includes('own-model')) + } finally { + store.close() + } +}) diff --git a/test/instances.test.ts b/test/instances.test.ts index 2867b5d..aff15be 100644 --- a/test/instances.test.ts +++ b/test/instances.test.ts @@ -169,3 +169,44 @@ test('the instance patch groups selected models under their providers', () => { store.close() } }) + +test('applyBotModels updates each configured bot through the bridge', async () => { + const updates: Array> = [] + const bridge = await startFakeServer(async (request, response) => { + const body = JSON.parse((await readBody(request)).toString('utf8')) as { endpoint: string; payload: Record } + if (body.endpoint === 'state') { + json(response, 200, { value: { bots: [{ id: 'lead-id', name: 'Repo Lead' }, { id: 'product-id', name: 'Repo Product' }] } }) + return + } + updates.push({ ...body.payload, endpoint: body.endpoint }) + json(response, 200, { ok: true }) + }) + const config = testConfig() + const store = new Store(':memory:') + const manager = new InstanceManager(store, config) + try { + const user = makeUser(store, { login: 'bot-apply-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_bot_apply', userId: user.id, owner: 'o', name: 'b', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const provider = store.createModelProvider({ id: 'prv_apply', userId: user.id, label: 'Provider', baseUrl: 'https://p.example/v1', keyEnc: 'enc', allowPrivate: false }) + const model = store.createProviderModel({ id: 'pvm_apply', providerId: provider.id, model: 'm', label: 'M' }) + store.setRepoModelSelection(repo.id, [model.id], model.id) + store.setRepoBotModel(repo.id, 'lead', model.id) + store.setRepoBotModel(repo.id, 'product', model.id) + store.saveRepoInstance({ + repo_id: repo.id, user_id: user.id, container: 'c', network: 'n', data_dir: '/tmp/bot-apply', bridge_script: '/tmp/bridge', + dsh_port: 1, bridge_port: Number(new URL(bridge.url).port), model_port: 2, model_bind: '127.0.0.1', image: 'image', + status: 'ready', chief_session: 'c', team_json: null, + bridge_token_enc: encryptSecret('bridge-token', config.tokenEncryptionKey), bridge_pid: null, log_pid: null, + last_error: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), last_health_at: null, + }) + const result = await manager.applyBotModels(repo.id) + assert.equal(result.updated, 2) + assert.deepEqual(result.skipped, []) + assert.equal(updates.length, 2) + assert.equal(updates[0]?.model, 'fledge-prv_apply/pvm_apply') + assert.equal(updates[0]?.endpoint, 'update-bot') + } finally { + store.close() + await bridge.close() + } +})