fix(server): index generation error handle (#15532)

<!--
Thank you for contributing to AFFiNE!

The PR title must follow Conventional Commits (enforced by CI):
type(scope): description e.g. fix(editor): keep selection after paste
Types: feat fix docs style refactor perf test build ci chore revert
-->

## Description

<!-- What does this PR do? Link related issues, e.g. "Closes #1234".
Screenshots or recordings are welcome for UI changes. -->

## Checklist

- [ ] I have signed the [AFFiNE Contributor License
Agreement](https://cla-assistant.io/toeverything/AFFiNE) — required
before merge; the `license/cla` check must be green ([how it
works](https://github.com/toeverything/AFFiNE/blob/canary/docs/BUILDING.md#sign-the-cla-first))
- [ ] The PR targets the `canary` branch and its title follows
[Conventional Commits](https://www.conventionalcommits.org/)
- [ ] Tests are added or updated where it makes sense
- [ ] `yarn lint` and `yarn typecheck` pass locally



#### PR Dependency Tree


* **PR #15532** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved search reconciliation and publication status handling when
workspace reconciliation fails.
* Prevented failed workspace reconciliation from incorrectly blocking
generation completion.
* Preserved active generation state so reconciliation can retry and
complete pending publications.
* Improved managed provider profile migration, including legacy
configurations, unavailable models, conflicting assignments, and missing
defaults.

* **Tests**
* Expanded coverage for workspace recovery and managed provider profile
migration scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-26 19:57:26 +08:00
committed by GitHub
parent 88585a2024
commit c91b1810bd
9 changed files with 251 additions and 87 deletions
@@ -143,6 +143,24 @@ test('managed provider migration preserves explicit profiles and converts legacy
models: ['@cf/baai/bge-reranker-base'],
config: { apiKey: 'profile-key' },
},
{
id: 'fal-default',
type: 'fal',
priority: 5,
config: { apiKey: 'existing-fal-key' },
},
{
id: 'anthropic-default',
type: 'anthropic',
priority: 2,
config: { apiKey: 'existing-anthropic-key' },
},
{
id: 'anthropicVertex-default',
type: 'anthropicVertex',
priority: 1,
config: { projectId: 'existing-anthropic-vertex-project' },
},
];
await t.context.db.appConfig.createMany({
data: [
@@ -151,6 +169,10 @@ test('managed provider migration preserves explicit profiles and converts legacy
id: 'copilot.providers.openai',
value: { apiKey: 'openai-key' },
},
{
id: 'copilot.providers.cloudflareWorkersAi',
value: { apiKey: 'legacy-cloudflare-key' },
},
{
id: 'copilot.providers.gemini',
value: { apiKey: 'gemini-key' },
@@ -159,6 +181,18 @@ test('managed provider migration preserves explicit profiles and converts legacy
id: 'copilot.providers.geminiVertex',
value: { projectId: 'gemini-vertex-project' },
},
{
id: 'copilot.providers.fal',
value: { apiKey: 'legacy-fal-key' },
},
{
id: 'copilot.providers.anthropic',
value: { apiKey: 'legacy-anthropic-key' },
},
{
id: 'copilot.providers.anthropicVertex',
value: { projectId: 'legacy-anthropic-vertex-project' },
},
{
id: 'copilot.providers.defaults',
value: { fallback: 'openai-default' },
@@ -173,7 +207,20 @@ test('managed provider migration preserves explicit profiles and converts legacy
where: { id: 'copilot.providers.profiles' },
});
t.deepEqual(migrated.value, [
...profiles,
profiles[0],
{
...profiles[1],
models: ['lora/image-to-image', 'workflowutils/teed'],
},
{
...profiles[2],
models: ['claude-sonnet-4-6'],
},
{
...profiles[3],
models: ['claude-sonnet-4-6'],
enabled: false,
},
{
id: 'openai-default',
type: 'openai',
@@ -181,6 +228,14 @@ test('managed provider migration preserves explicit profiles and converts legacy
models: ['gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-image-1', 'gpt-4o-mini'],
config: { apiKey: 'openai-key' },
},
{
id: 'cloudflareWorkersAi-default',
type: 'cloudflareWorkersAi',
priority: 6,
models: ['@cf/baai/bge-reranker-base'],
config: { apiKey: 'legacy-cloudflare-key' },
enabled: false,
},
{
id: 'gemini-default',
type: 'gemini',
@@ -203,8 +258,12 @@ test('managed provider migration preserves explicit profiles and converts legacy
id: {
in: [
'copilot.providers.openai',
'copilot.providers.cloudflareWorkersAi',
'copilot.providers.gemini',
'copilot.providers.geminiVertex',
'copilot.providers.fal',
'copilot.providers.anthropic',
'copilot.providers.anthropicVertex',
],
},
},
@@ -216,6 +275,38 @@ test('managed provider migration preserves explicit profiles and converts legacy
where: { id: 'copilot.providers.defaults' },
})
);
await t.context.db.appConfig.delete({
where: { id: 'copilot.providers.defaults' },
});
const defaultOnlyProfiles = profiles.slice(1);
await t.context.db.appConfig.update({
where: { id: 'copilot.providers.profiles' },
data: { value: defaultOnlyProfiles },
});
await ConvergeManagedProviderProfiles1786810000000.up(t.context.db);
t.deepEqual(
(
await t.context.db.appConfig.findUniqueOrThrow({
where: { id: 'copilot.providers.profiles' },
})
).value,
[
{
...defaultOnlyProfiles[0],
models: ['lora/image-to-image', 'workflowutils/teed'],
},
{
...defaultOnlyProfiles[1],
models: ['claude-sonnet-4-6'],
},
{
...defaultOnlyProfiles[2],
models: ['claude-sonnet-4-6'],
enabled: false,
},
]
);
await t.context.db.appConfig.update({
where: { id: 'copilot.providers.profiles' },
@@ -13,6 +13,9 @@ const PROVIDERS = [
] as const;
const PROVIDER_IDS = PROVIDERS.map(provider => `copilot.providers.${provider}`);
const DEFAULT_PROFILE_IDS = new Set(
PROVIDERS.map(provider => `${provider}-default`)
);
const PROVIDER_MODELS: Record<(typeof PROVIDERS)[number], string[]> = {
openai: ['gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-image-1', 'gpt-4o-mini'],
cloudflareWorkersAi: ['@cf/baai/bge-reranker-base'],
@@ -60,16 +63,11 @@ export class ConvergeManagedProviderProfiles1786810000000 {
const byId = new Map(rows.map(row => [row.id, row]));
const profileRow = byId.get(PROFILE_KEY);
const profiles = readProfiles(profileRow?.value);
const profileIds = new Set(
profiles.flatMap(profile =>
isRecord(profile) && typeof profile.id === 'string'
? [profile.id]
: []
)
);
const assignedModels = new Set(
profiles.flatMap(profile =>
isRecord(profile) &&
typeof profile.id === 'string' &&
!DEFAULT_PROFILE_IDS.has(profile.id) &&
profile.enabled !== false &&
Array.isArray(profile.models)
? profile.models.filter(
@@ -78,33 +76,69 @@ export class ConvergeManagedProviderProfiles1786810000000 {
: []
)
);
let converged = false;
for (const [index, provider] of PROVIDERS.entries()) {
const legacy = byId.get(`copilot.providers.${provider}`);
if (!legacy) continue;
if (!isRecord(legacy.value)) {
if (legacy && !isRecord(legacy.value)) {
throw new Error(`copilot.providers.${provider} must be an object`);
}
const id = `${provider}-default`;
if (!profileIds.has(id)) {
const models = PROVIDER_MODELS[provider].filter(
model => !assignedModels.has(model)
);
const enabled = models.length > 0;
profiles.push({
id,
type: provider,
priority: PROVIDERS.length - index,
models: enabled ? models : PROVIDER_MODELS[provider],
config: legacy.value,
...(enabled ? {} : { enabled: false }),
const profileIndex = profiles.findIndex(
profile => isRecord(profile) && profile.id === id
);
const existing =
profileIndex === -1 ? undefined : profiles[profileIndex];
if (!legacy && !existing) continue;
converged = true;
const configuredModels =
isRecord(existing) &&
Array.isArray(existing.models) &&
existing.models.length > 0
? existing.models
: PROVIDER_MODELS[provider];
const canEnable =
!isRecord(existing) ||
existing.enabled === undefined ||
existing.enabled === true;
const modelsAreValid = configuredModels.every(
model => typeof model === 'string'
);
const availableModels =
canEnable && modelsAreValid
? configuredModels.filter(model => !assignedModels.has(model))
: configuredModels;
const hasConflict = canEnable && availableModels.length === 0;
const models = hasConflict ? configuredModels : availableModels;
const profile: Prisma.JsonObject = {
...(legacy
? {
id,
type: provider,
priority: PROVIDERS.length - index,
config: legacy.value,
}
: {}),
...(isRecord(existing) ? existing : {}),
id,
type: provider,
models,
...(hasConflict ? { enabled: false } : {}),
};
if (profileIndex === -1) {
profiles.push(profile);
} else {
profiles[profileIndex] = profile;
}
if (canEnable && !hasConflict) {
models.forEach(model => {
if (typeof model === 'string') assignedModels.add(model);
});
models.forEach(model => assignedModels.add(model));
profileIds.add(id);
}
}
if (PROVIDER_IDS.some(id => byId.has(id))) {
if (converged) {
validateProfiles(profiles);
await tx.appConfig.upsert({
where: { id: PROFILE_KEY },