fix(owner): correct per-session ownerId cache and share one PreferencesManager

On name-match platforms (WhatsApp/Line/Instagram), applyProfileToOtherSessions
writes the local member's platformId to each session, which can differ from the
source ownerId. The session store was caching the source ownerId for all updated
sessions, causing owner display misalignment before a full reload.

Fix: applyProfileToOtherSessions now returns {id, ownerId}[] so the service can
expose updatedSessionOwnerIds in the result; the store uses that map instead of
the single source ownerId.

Also fix a stale-cache issue: registerSessionRoutes and registerPreferencesRoutes
each created their own PreferencesManager when ctx.preferencesManager was absent
(CLI and Electron both omit it). Two independent caches caused a later PATCH to
preferences to deep-merge against stale data and overwrite ownerProfilesByPlatform
back to its pre-selection value. registerSharedRoutes now creates one shared
instance and passes it to all routes via resolvedCtx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Sonnet 4.6 committed 2026-06-13 13:16:52 +08:00
1 parent 82ce9dcc2f
commit 68cc5ffcee
5 files changed
+59 -34

No files matched your search

+35 -28
View File
@@ -7,6 +7,7 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from './context'
import { PreferencesManager } from '@openchatlab/node-runtime'
import { registerSystemRoutes } from './routes/system'
import { registerRestSessionRoutes } from './routes/sessions'
import { registerSessionRoutes } from './routes/web/sessions'
@@ -38,47 +39,53 @@ export function registerSharedRoutes(
ctx: HttpRouteContext,
options?: SharedRouteOptions
): void {
// Ensure all routes share one PreferencesManager instance to avoid stale-cache
// overwrites between the session owner routes and the preferences routes.
const resolvedCtx: HttpRouteContext = ctx.preferencesManager
? ctx
: { ...ctx, preferencesManager: new PreferencesManager(ctx.pathProvider.getSystemDir()) }
// REST API (/api/v1/*)
registerSystemRoutes(server, ctx)
registerRestSessionRoutes(server, ctx)
registerSystemRoutes(server, resolvedCtx)
registerRestSessionRoutes(server, resolvedCtx)
// Web UI API (/_web/*)
registerSessionRoutes(server, ctx)
registerMemberRoutes(server, ctx)
registerPreferencesRoutes(server, ctx)
registerAnalyticsRoutes(server, ctx)
registerSqlRoutes(server, ctx)
registerSessionIndexRoutes(server, ctx)
registerExportRoutes(server, ctx)
registerNlpRoutes(server, ctx)
registerSessionRoutes(server, resolvedCtx)
registerMemberRoutes(server, resolvedCtx)
registerPreferencesRoutes(server, resolvedCtx)
registerAnalyticsRoutes(server, resolvedCtx)
registerSqlRoutes(server, resolvedCtx)
registerSessionIndexRoutes(server, resolvedCtx)
registerExportRoutes(server, resolvedCtx)
registerNlpRoutes(server, resolvedCtx)
if (options?.requireAi) {
const missing: string[] = []
if (!ctx.aiDataDir) missing.push('aiDataDir')
if (!ctx.aiChatManager) missing.push('aiChatManager')
if (!ctx.assistantManager) missing.push('assistantManager')
if (!ctx.skillManagerCore) missing.push('skillManagerCore')
if (!ctx.llmConfigStore) missing.push('llmConfigStore')
if (!ctx.customProviderStore) missing.push('customProviderStore')
if (!ctx.customModelStore) missing.push('customModelStore')
if (!ctx.runAgentStream) missing.push('runAgentStream')
if (!resolvedCtx.aiDataDir) missing.push('aiDataDir')
if (!resolvedCtx.aiChatManager) missing.push('aiChatManager')
if (!resolvedCtx.assistantManager) missing.push('assistantManager')
if (!resolvedCtx.skillManagerCore) missing.push('skillManagerCore')
if (!resolvedCtx.llmConfigStore) missing.push('llmConfigStore')
if (!resolvedCtx.customProviderStore) missing.push('customProviderStore')
if (!resolvedCtx.customModelStore) missing.push('customModelStore')
if (!resolvedCtx.runAgentStream) missing.push('runAgentStream')
if (missing.length > 0) {
throw new Error(`[http-routes] requireAi is set but missing AI dependencies: ${missing.join(', ')}`)
}
}
registerAiAssistantRoutes(server, ctx)
registerAiSkillRoutes(server, ctx)
registerAiLlmRoutes(server, ctx)
registerAiLlmStreamRoutes(server, ctx)
registerAiAgentStreamRoutes(server, ctx)
registerAiToolRoutes(server, ctx)
registerAiChatRoutes(server, ctx)
registerAiSummaryRoutes(server, ctx)
registerAiAssistantRoutes(server, resolvedCtx)
registerAiSkillRoutes(server, resolvedCtx)
registerAiLlmRoutes(server, resolvedCtx)
registerAiLlmStreamRoutes(server, resolvedCtx)
registerAiAgentStreamRoutes(server, resolvedCtx)
registerAiToolRoutes(server, resolvedCtx)
registerAiChatRoutes(server, resolvedCtx)
registerAiSummaryRoutes(server, resolvedCtx)
// Merge routes (graceful skip when mergeSessionCache is absent)
registerMergeRoutes(server, ctx)
registerMergeRoutes(server, resolvedCtx)
// Cache/storage routes
registerCacheRoutes(server, ctx)
registerCacheRoutes(server, resolvedCtx)
}
@@ -138,6 +138,7 @@ test('setOwnerAndApplyProfile writes owner, saves platform profile and merges na
assert.equal(result.ownerId, 'Alice')
assert.equal(result.platform, 'whatsapp')
assert.deepEqual(result.updatedSessionIds, [])
assert.deepEqual(result.updatedSessionOwnerIds, {})
assert.equal(env.ownerOf('s1'), 'Alice')
env.preferences.invalidateCache()
@@ -170,6 +171,8 @@ test('setOwnerAndApplyProfile batch-applies to unowned same-platform sessions on
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice')
assert.deepEqual(result.updatedSessionIds, ['name-match'])
// name-match session has platformId 'alice' (lowercase) — must differ from source ownerId 'Alice'
assert.deepEqual(result.updatedSessionOwnerIds, { 'name-match': 'alice' })
assert.equal(env.ownerOf('name-match'), 'alice')
assert.equal(env.ownerOf('owned'), 'Bob')
assert.equal(env.ownerOf('other-platform'), null)
@@ -303,6 +306,7 @@ test('exact platformId match works on platforms without name fallback', (t) => {
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'wx_me')
assert.equal(result.platform, 'weixin')
assert.deepEqual(result.updatedSessionIds, ['other'])
assert.deepEqual(result.updatedSessionOwnerIds, { other: 'wx_me' })
assert.equal(env.ownerOf('other'), 'wx_me')
env.preferences.invalidateCache()
@@ -113,7 +113,12 @@ export function setOwnerAndApplyProfile(
updatedAt: Date.now(),
}
const updatedSessionIds = applyProfileToOtherSessions(adapter, meta.platform, profile, sessionId)
const updatedSessions = applyProfileToOtherSessions(adapter, meta.platform, profile, sessionId)
const updatedSessionIds = updatedSessions.map((s) => s.id)
const updatedSessionOwnerIds: Record<string, string> = {}
for (const s of updatedSessions) {
updatedSessionOwnerIds[s.id] = s.ownerId
}
const noLongerDismissed = new Set([sessionId, ...updatedSessionIds])
preferences.save({
@@ -121,20 +126,22 @@ export function setOwnerAndApplyProfile(
ownerPromptDismissedSessionIds: prefs.ownerPromptDismissedSessionIds.filter((id) => !noLongerDismissed.has(id)),
})
return { sessionId, platform: meta.platform, ownerId: ownerPlatformId, updatedSessionIds }
return { sessionId, platform: meta.platform, ownerId: ownerPlatformId, updatedSessionIds, updatedSessionOwnerIds }
}
/**
* Apply a profile to all other unowned sessions of the same platform.
* Unique match only; sessions with an existing owner are never touched.
* Returns the id and the actual platformId written for each updated session
* (may differ from profile.platformId on name-match platforms).
*/
function applyProfileToOtherSessions(
adapter: SessionRuntimeAdapter,
platform: string,
profile: OwnerProfile,
excludeSessionId: string
): string[] {
const updated: string[] = []
): Array<{ id: string; ownerId: string }> {
const updated: Array<{ id: string; ownerId: string }> = []
for (const id of adapter.listSessionIds()) {
if (id === excludeSessionId) continue
try {
@@ -146,7 +153,7 @@ function applyProfileToOtherSessions(
const result = matchOwnerProfile(platform, profile, getMembersWithAliases(db))
if (result.type === 'exact' || result.type === 'name') {
coreUpdateSessionOwnerId(adapter.ensureWritable(id), result.platformId)
updated.push(id)
updated.push({ id, ownerId: result.platformId })
}
} catch (err) {
console.warn(`[OwnerProfile] Failed to apply profile to session ${id}:`, err)
+7
View File
@@ -216,6 +216,13 @@ export interface SetOwnerAndApplyProfileResult {
ownerId: string
/** Other same-platform sessions auto-filled by the updated profile. */
updatedSessionIds: string[]
/**
* The actual owner_id written to each updated session.
* On name-match platforms the matched member's platformId can differ from
* ownerId (the source session's platformId), so callers must use this map
* rather than ownerId when caching the result for updated sessions.
*/
updatedSessionOwnerIds: Record<string, string>
}
export interface Preferences {
+1 -1
View File
@@ -697,7 +697,7 @@ export const useSessionStore = defineStore(
for (const sessionId of [id, ...result.updatedSessionIds]) {
const session = sessions.value.find((s) => s.id === sessionId)
if (session) {
session.ownerId = result.ownerId
session.ownerId = result.updatedSessionOwnerIds[sessionId] ?? result.ownerId
}
}
return result