feat(owner): add owner profile service and shared web routes

Shared node-runtime service writes session owner, maintains the
per-platform profile in preferences.json, and batch-applies it to
other unowned same-platform sessions (unique match only, never
overriding an existing owner). Exposed via /_web/sessions/:id/owner/
{apply-profile,select,dismiss-prompt} for both Electron and CLI Web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Fable 5 committed 2026-06-13 13:16:52 +08:00
1 parent 7589515eeb
commit a75d17e310
6 files changed
+605 -2

No files matched your search

@@ -7,10 +7,13 @@
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import Database from 'better-sqlite3'
import Fastify, { type FastifyInstance } from 'fastify'
import type { DatabaseAdapter, PathProvider, PreparedStatement, RunResult } from '@openchatlab/core'
import type { DatabaseManager, SessionRuntimeAdapter } from '@openchatlab/node-runtime'
import { PreferencesManager, type DatabaseManager, type SessionRuntimeAdapter } from '@openchatlab/node-runtime'
import type { HttpRouteContext } from '../context'
import { registerSharedRoutes } from '../register'
import { registerRestSessionRoutes } from './sessions'
@@ -293,4 +296,64 @@ describe('registerSharedRoutes smoke tests', () => {
assert.deepEqual(resp.json(), { success: true })
assert.equal(dbs.has('chat-1'), false)
})
it('owner profile routes select, apply and dismiss across sessions', async () => {
const prefDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-owner-routes-'))
const current = createSessionDb()
current.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`)
const other = createSessionDb()
other.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`)
const dismissedOnly = createSessionDb()
dismissedOnly.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'; DELETE FROM member`)
const dbs = new Map<string, DatabaseAdapter>([
['chat-1', current],
['chat-2', other],
['chat-3', dismissedOnly],
])
const ctx = createTestContext(dbs)
ctx.preferencesManager = new PreferencesManager(prefDir)
const routeApp = Fastify()
registerSessionRoutes(routeApp, ctx)
await routeApp.ready()
try {
// Dismiss the prompt for chat-3 (no matching member there)
const dismissResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/dismiss-prompt' })
assert.equal(dismissResp.statusCode, 200)
assert.deepEqual(dismissResp.json(), { success: true })
// apply-profile before any profile exists reports no_profile and the dismissed flag
const earlyApply = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/apply-profile' })
assert.equal(earlyApply.statusCode, 200)
assert.deepEqual(earlyApply.json(), { applied: false, reason: 'no_profile', dismissed: true })
// Manual selection writes owner, saves profile and batch-applies to chat-2
const selectResp = await routeApp.inject({
method: 'POST',
url: '/_web/sessions/chat-1/owner/select',
payload: { ownerPlatformId: 'alice' },
})
assert.equal(selectResp.statusCode, 200)
const selectBody = selectResp.json()
assert.equal(selectBody.ownerId, 'alice')
assert.equal(selectBody.platform, 'whatsapp')
assert.deepEqual(selectBody.updatedSessionIds, ['chat-2'])
const currentMeta = current.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string }
const otherMeta = other.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string }
assert.equal(currentMeta.owner_id, 'alice')
assert.equal(otherMeta.owner_id, 'alice')
// apply-profile on an already-owned session reports already_set
const applyResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-2/owner/apply-profile' })
assert.equal(applyResp.statusCode, 200)
assert.deepEqual(applyResp.json(), { applied: false, ownerId: 'alice', reason: 'already_set', dismissed: false })
} finally {
await routeApp.close()
current.close()
other.close()
dismissedOnly.close()
fs.rmSync(prefDir, { recursive: true, force: true })
}
})
})
@@ -1,10 +1,17 @@
import type { FastifyInstance } from 'fastify'
import type { HttpRouteContext } from '../../context'
import { sessionService } from '@openchatlab/node-runtime'
import { sessionService, ownerProfileService, PreferencesManager } from '@openchatlab/node-runtime'
export function registerSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void {
const { sessionAdapter: adapter } = ctx
// Lazy: only owner-profile routes need preferences.json access
let preferencesInstance: PreferencesManager | null = null
const preferences = () => {
preferencesInstance ??= ctx.preferencesManager ?? new PreferencesManager(ctx.pathProvider.getSystemDir())
return preferencesInstance
}
server.get('/_web/sessions', async () => {
return sessionService.listAnalysisSessions(adapter)
})
@@ -42,4 +49,29 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: HttpRouteCon
return { success: true }
}
)
// Try to auto-apply the stored platform owner profile to this session.
server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/apply-profile', async (request) => {
return ownerProfileService.tryApplyOwnerProfile(adapter, preferences(), request.params.id)
})
// Manually select owner: writes meta.owner_id, updates the platform profile,
// and batch-applies it to other unowned same-platform sessions.
server.post<{ Params: { id: string }; Body: { ownerPlatformId: string } }>(
'/_web/sessions/:id/owner/select',
async (request) => {
return ownerProfileService.setOwnerAndApplyProfile(
adapter,
preferences(),
request.params.id,
request.body.ownerPlatformId
)
}
)
// Suppress the owner prompt for this session (UI-only).
server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/dismiss-prompt', async (request) => {
ownerProfileService.dismissOwnerPrompt(preferences(), request.params.id)
return { success: true }
})
}
+4
View File
@@ -404,6 +404,7 @@ export * as memberService from './services/member-service'
export * as sessionIndexService from './services/session-index-service'
export * as summaryService from './services/summary-service'
export * as exportService from './services/export-service'
export * as ownerProfileService from './services/owner-profile-service'
export { createDatabaseManagerAdapter, MergeSessionCache } from './services'
export type {
SessionRuntimeAdapter,
@@ -412,4 +413,7 @@ export type {
MembersPaginatedDTO,
LlmConfig,
SummaryServiceDeps,
ApplyOwnerProfileReason,
ApplyOwnerProfileResult,
SetOwnerAndApplyProfileResult,
} from './services'
@@ -23,6 +23,19 @@ export {
} from './member-service'
export type { MembersPaginatedDTO } from './member-service'
// Owner profile service
export {
tryApplyOwnerProfile,
setOwnerAndApplyProfile,
dismissOwnerPrompt,
clearSessionOwner,
} from './owner-profile-service'
export type {
ApplyOwnerProfileReason,
ApplyOwnerProfileResult,
SetOwnerAndApplyProfileResult,
} from './owner-profile-service'
// Session index service
export {
generateIndex,
@@ -0,0 +1,314 @@
/**
* Integration tests for the owner profile service, against real SQLite
* session databases and a real preferences.json file.
*
* Run: pnpm test -- packages/node-runtime/src/services/owner-profile-service.test.ts
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { CHAT_DB_SCHEMA, getSessionMeta } from '@openchatlab/core'
import type { DatabaseAdapter } from '@openchatlab/core'
import { openBetterSqliteDatabase } from '../better-sqlite3-adapter'
import { PreferencesManager } from '../preferences'
import type { SessionRuntimeAdapter } from './adapters'
import {
tryApplyOwnerProfile,
setOwnerAndApplyProfile,
dismissOwnerPrompt,
clearSessionOwner,
} from './owner-profile-service'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-owner-profile-'))
}
interface SeedMember {
platformId: string
accountName?: string
groupNickname?: string
aliases?: string[]
}
interface SeedSession {
id: string
platform: string
type?: 'group' | 'private'
ownerId?: string | null
members: SeedMember[]
}
class TestEnv {
readonly dir: string
readonly preferences: PreferencesManager
readonly adapter: SessionRuntimeAdapter
private dbPaths = new Map<string, string>()
private openDbs: DatabaseAdapter[] = []
constructor() {
this.dir = makeTempDir()
this.preferences = new PreferencesManager(this.dir)
const open = (sessionId: string, readonly: boolean): DatabaseAdapter | null => {
const dbPath = this.dbPaths.get(sessionId)
if (!dbPath) return null
const db = openBetterSqliteDatabase(dbPath, { readonly, nativeBinding })
this.openDbs.push(db)
return db
}
this.adapter = {
listSessionIds: () => [...this.dbPaths.keys()],
openReadonly: (id) => open(id, true),
openWritable: (id) => open(id, false),
closeSession: () => {},
getDbPath: (id) => this.dbPaths.get(id) ?? '',
deleteSessionFile: () => false,
ensureReadonly: (id) => {
const db = open(id, true)
if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 })
return db
},
ensureWritable: (id) => {
const db = open(id, false)
if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 })
return db
},
}
}
seed(session: SeedSession): void {
const dbPath = path.join(this.dir, `${session.id}.db`)
const db = openBetterSqliteDatabase(dbPath, { nativeBinding })
db.exec(CHAT_DB_SCHEMA)
db.prepare(`INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES (?, ?, ?, ?, ?)`).run(
session.id,
session.platform,
session.type ?? 'group',
1780000000,
session.ownerId ?? null
)
for (const member of session.members) {
db.prepare(`INSERT INTO member (platform_id, account_name, group_nickname, aliases) VALUES (?, ?, ?, ?)`).run(
member.platformId,
member.accountName ?? null,
member.groupNickname ?? null,
JSON.stringify(member.aliases ?? [])
)
}
db.close()
this.dbPaths.set(session.id, dbPath)
}
ownerOf(sessionId: string): string | null {
const db = this.adapter.ensureReadonly(sessionId)
return getSessionMeta(db)?.ownerId ?? null
}
cleanup(): void {
for (const db of this.openDbs) {
try {
db.close()
} catch {
// already closed
}
}
fs.rmSync(this.dir, { recursive: true, force: true })
}
}
test('setOwnerAndApplyProfile writes owner, saves platform profile and merges names', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({
id: 's1',
platform: 'whatsapp',
members: [
{ platformId: 'Alice', accountName: 'Alice', groupNickname: 'Ali', aliases: ['Allie'] },
{ platformId: 'Bob' },
],
})
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice')
assert.equal(result.ownerId, 'Alice')
assert.equal(result.platform, 'whatsapp')
assert.deepEqual(result.updatedSessionIds, [])
assert.equal(env.ownerOf('s1'), 'Alice')
env.preferences.invalidateCache()
const profile = env.preferences.load().ownerProfilesByPlatform['whatsapp']
assert.ok(profile)
assert.equal(profile.platformId, 'Alice')
assert.equal(profile.matchMode, 'name')
assert.deepEqual(profile.confirmedNames, ['Alice', 'Ali', 'Allie'])
})
test('setOwnerAndApplyProfile batch-applies to unowned same-platform sessions only', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] })
// Name fallback match (different platformId, same normalized name)
env.seed({ id: 'name-match', platform: 'whatsapp', members: [{ platformId: 'alice' }, { platformId: 'Carol' }] })
// Already owned: never overridden
env.seed({ id: 'owned', platform: 'whatsapp', ownerId: 'Bob', members: [{ platformId: 'Alice' }] })
// Different platform: untouched
env.seed({ id: 'other-platform', platform: 'telegram', members: [{ platformId: 'Alice' }] })
// Ambiguous: two members normalize to the same confirmed name
env.seed({
id: 'ambiguous',
platform: 'whatsapp',
members: [{ platformId: 'alice' }, { platformId: 'u2', accountName: 'ALICE' }],
})
// No match
env.seed({ id: 'no-match', platform: 'whatsapp', members: [{ platformId: 'Dave' }] })
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice')
assert.deepEqual(result.updatedSessionIds, ['name-match'])
assert.equal(env.ownerOf('name-match'), 'alice')
assert.equal(env.ownerOf('owned'), 'Bob')
assert.equal(env.ownerOf('other-platform'), null)
assert.equal(env.ownerOf('ambiguous'), null)
assert.equal(env.ownerOf('no-match'), null)
})
test('re-selecting a different member replaces the profile instead of merging names', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] })
setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice')
setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Bob')
env.preferences.invalidateCache()
const profile = env.preferences.load().ownerProfilesByPlatform['whatsapp']
assert.equal(profile.platformId, 'Bob')
assert.deepEqual(profile.confirmedNames, ['Bob'])
})
test('setOwnerAndApplyProfile rejects a platformId that is not a session member', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
assert.throws(() => setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Nobody'), /Member not found/)
})
test('tryApplyOwnerProfile applies stored profile and reports reasons', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 'source', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
env.seed({ id: 'unowned', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] })
env.seed({ id: 'no-profile', platform: 'telegram', members: [{ platformId: 'Alice' }] })
// No profile saved yet
assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), {
applied: false,
reason: 'no_profile',
dismissed: false,
})
setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'Alice')
// 'unowned' was already auto-filled by batch apply
assert.equal(env.ownerOf('unowned'), 'Alice')
assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), {
applied: false,
ownerId: 'Alice',
reason: 'already_set',
dismissed: false,
})
// New session imported later: profile applies on demand
env.seed({ id: 'later', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Carol' }] })
const applied = tryApplyOwnerProfile(env.adapter, env.preferences, 'later')
assert.deepEqual(applied, { applied: true, ownerId: 'Alice', dismissed: false })
assert.equal(env.ownerOf('later'), 'Alice')
assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'no-profile'), {
applied: false,
reason: 'no_profile',
dismissed: false,
})
assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'missing').reason, 'missing_session')
})
test('dismissOwnerPrompt persists and is cleared by manual owner selection', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
dismissOwnerPrompt(env.preferences, 's1')
dismissOwnerPrompt(env.preferences, 's1')
env.preferences.invalidateCache()
assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, ['s1'])
assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 's1').dismissed, true)
setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice')
env.preferences.invalidateCache()
assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, [])
})
test('batch apply removes auto-filled sessions from the dismissed list', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
env.seed({ id: 'other', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] })
dismissOwnerPrompt(env.preferences, 'other')
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice')
assert.deepEqual(result.updatedSessionIds, ['other'])
env.preferences.invalidateCache()
assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, [])
})
test('clearSessionOwner clears the session owner but keeps the platform profile', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] })
setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice')
assert.equal(env.ownerOf('s1'), 'Alice')
clearSessionOwner(env.adapter, 's1')
assert.equal(env.ownerOf('s1'), null)
env.preferences.invalidateCache()
assert.ok(env.preferences.load().ownerProfilesByPlatform['whatsapp'])
})
test('exact platformId match works on platforms without name fallback', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
env.seed({ id: 'source', platform: 'weixin', members: [{ platformId: 'wx_me', accountName: 'Me' }] })
env.seed({
id: 'other',
platform: 'weixin',
members: [
{ platformId: 'wx_me', accountName: 'Renamed' },
{ platformId: 'wx_other', accountName: 'Me' },
],
})
const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'wx_me')
assert.equal(result.platform, 'weixin')
assert.deepEqual(result.updatedSessionIds, ['other'])
assert.equal(env.ownerOf('other'), 'wx_me')
env.preferences.invalidateCache()
assert.equal(env.preferences.load().ownerProfilesByPlatform['weixin'].matchMode, 'platform_id')
// Name-only coincidence must NOT match on weixin
env.seed({ id: 'name-only', platform: 'weixin', members: [{ platformId: 'wx_x', accountName: 'Me' }] })
assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'name-only').reason, 'no_match')
})
@@ -0,0 +1,177 @@
/**
* Shared owner profile service.
*
* Manages the platform-level "who am I" identity: writes per-session
* meta.owner_id, maintains ownerProfilesByPlatform in preferences.json, and
* applies a confirmed profile to other unowned sessions of the same platform.
* Used by both CLI Web routes and Electron IPC.
*/
import {
getSessionMeta,
getMembersWithAliases,
isChatSessionDb,
updateSessionOwnerId as coreUpdateSessionOwnerId,
matchOwnerProfile,
mergeConfirmedNames,
isNameMatchPlatform,
} from '@openchatlab/core'
import type { MemberWithAliases } from '@openchatlab/core'
import type {
OwnerProfile,
ApplyOwnerProfileReason,
ApplyOwnerProfileResult,
SetOwnerAndApplyProfileResult,
} from '@openchatlab/shared-types'
import type { PreferencesManager } from '../preferences'
import type { SessionRuntimeAdapter } from './adapters'
export type { ApplyOwnerProfileReason, ApplyOwnerProfileResult, SetOwnerAndApplyProfileResult }
function memberDisplayName(member: MemberWithAliases): string {
return member.groupNickname || member.accountName || member.platformId
}
function isDismissed(preferences: PreferencesManager, sessionId: string): boolean {
return preferences.load().ownerPromptDismissedSessionIds.includes(sessionId)
}
/**
* Try to apply the stored platform owner profile to one session.
* Writes meta.owner_id only on a unique (exact or name) match.
* Never overrides an existing owner.
*/
export function tryApplyOwnerProfile(
adapter: SessionRuntimeAdapter,
preferences: PreferencesManager,
sessionId: string
): ApplyOwnerProfileResult {
const dismissed = isDismissed(preferences, sessionId)
const db = adapter.openReadonly(sessionId)
if (!db || !isChatSessionDb(db)) {
return { applied: false, reason: 'missing_session', dismissed }
}
const meta = getSessionMeta(db)
if (!meta) {
return { applied: false, reason: 'missing_session', dismissed }
}
if (meta.ownerId) {
return { applied: false, ownerId: meta.ownerId, reason: 'already_set', dismissed }
}
const profile = preferences.load().ownerProfilesByPlatform[meta.platform]
if (!profile) {
return { applied: false, reason: 'no_profile', dismissed }
}
const result = matchOwnerProfile(meta.platform, profile, getMembersWithAliases(db))
if (result.type === 'exact' || result.type === 'name') {
const writable = adapter.ensureWritable(sessionId)
coreUpdateSessionOwnerId(writable, result.platformId)
return { applied: true, ownerId: result.platformId, dismissed }
}
return { applied: false, reason: result.type === 'ambiguous' ? 'ambiguous' : 'no_match', dismissed }
}
/**
* Manually set the owner of one session, update the platform profile, and
* batch-apply the profile to other unowned sessions of the same platform.
*
* Re-selecting the same identity merges new names into confirmedNames;
* selecting a different member replaces the profile (names start fresh,
* so the previous person's names cannot cause wrong matches).
*/
export function setOwnerAndApplyProfile(
adapter: SessionRuntimeAdapter,
preferences: PreferencesManager,
sessionId: string,
ownerPlatformId: string
): SetOwnerAndApplyProfileResult {
const db = adapter.ensureWritable(sessionId)
const meta = getSessionMeta(db)
if (!meta) {
throw Object.assign(new Error(`Session has no meta: ${sessionId}`), { statusCode: 404 })
}
const members = getMembersWithAliases(db)
const selected = members.find((m) => m.platformId === ownerPlatformId)
if (!selected) {
throw Object.assign(new Error(`Member not found in session: ${ownerPlatformId}`), { statusCode: 400 })
}
coreUpdateSessionOwnerId(db, ownerPlatformId)
const prefs = preferences.load()
const existing = prefs.ownerProfilesByPlatform[meta.platform]
const baseNames = existing && existing.platformId === ownerPlatformId ? existing.confirmedNames : []
const profile: OwnerProfile = {
platformId: ownerPlatformId,
displayName: memberDisplayName(selected),
confirmedNames: mergeConfirmedNames(baseNames, { ...selected, displayName: memberDisplayName(selected) }),
matchMode: isNameMatchPlatform(meta.platform) ? 'name' : 'platform_id',
updatedAt: Date.now(),
}
const updatedSessionIds = applyProfileToOtherSessions(adapter, meta.platform, profile, sessionId)
const noLongerDismissed = new Set([sessionId, ...updatedSessionIds])
preferences.save({
ownerProfilesByPlatform: { ...prefs.ownerProfilesByPlatform, [meta.platform]: profile },
ownerPromptDismissedSessionIds: prefs.ownerPromptDismissedSessionIds.filter((id) => !noLongerDismissed.has(id)),
})
return { sessionId, platform: meta.platform, ownerId: ownerPlatformId, updatedSessionIds }
}
/**
* Apply a profile to all other unowned sessions of the same platform.
* Unique match only; sessions with an existing owner are never touched.
*/
function applyProfileToOtherSessions(
adapter: SessionRuntimeAdapter,
platform: string,
profile: OwnerProfile,
excludeSessionId: string
): string[] {
const updated: string[] = []
for (const id of adapter.listSessionIds()) {
if (id === excludeSessionId) continue
try {
const db = adapter.openReadonly(id)
if (!db || !isChatSessionDb(db)) continue
const meta = getSessionMeta(db)
if (!meta || meta.platform !== platform || meta.ownerId) continue
const result = matchOwnerProfile(platform, profile, getMembersWithAliases(db))
if (result.type === 'exact' || result.type === 'name') {
coreUpdateSessionOwnerId(adapter.ensureWritable(id), result.platformId)
updated.push(id)
}
} catch (err) {
console.warn(`[OwnerProfile] Failed to apply profile to session ${id}:`, err)
}
}
return updated
}
/**
* Suppress the owner prompt for one session. UI-only: does not imply owner
* knowledge and does not block later automatic profile application.
*/
export function dismissOwnerPrompt(preferences: PreferencesManager, sessionId: string): void {
const prefs = preferences.load()
if (prefs.ownerPromptDismissedSessionIds.includes(sessionId)) return
preferences.save({
ownerPromptDismissedSessionIds: [...prefs.ownerPromptDismissedSessionIds, sessionId],
})
}
/**
* Clear the owner of one session only. The platform profile is kept and may
* re-apply automatically later if it still uniquely matches.
*/
export function clearSessionOwner(adapter: SessionRuntimeAdapter, sessionId: string): void {
const db = adapter.ensureWritable(sessionId)
coreUpdateSessionOwnerId(db, null)
}