Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/routing.ts
T

425 lines
15 KiB
TypeScript

/**
* Cross-connection routing: resolving a roster row's owning connection, the
* RPC door that rides that owner, and the alias identity that keeps a
* configured Desktop alias attached to the backend row it activates into.
*
* The bottom of the Bot Mode module graph — it imports no sibling module, so
* everything else can dispatch through it without a cycle.
*/
import { host } from '@hermes/plugin-sdk'
import type { BotMeta, ProfileRoute, RosterRow } from './types'
export function botRouteKey(route: ProfileRoute): string {
return `${route.connectionId}::${route.profile}`
}
// ── cross-connection routing ─────────────────────────────────────────────────
// A bot from another registered connection (remoteSource rows) is reached
// through host.requestProfile with a route descriptor; local bots keep the
// active-gateway door. Feature-detected: older desktops without
// requestProfile simply have no remote routes (callers fall back / disable).
/** Outcome of resolving a row's owning connection. `profile` is only carried
* on the `owner_removed` branch, where the strict wrapper needs it for the
* error message. */
interface BotRouteResolution {
profile?: string
route: ProfileRoute | null
status: 'not_scoped' | 'owner_removed' | 'resolved'
}
/** Non-throwing resolver behind botConnectionRoute(). Returns a typed status
* instead of throwing, so passive callers (display/meta lookups) can branch
* on `resolved | owner_removed | not_scoped` rather than catching whatever
* exception the strict wrapper below happens to throw. */
export function resolveBotConnectionRoute(bot: Partial<RosterRow> | null | undefined): BotRouteResolution {
if (!bot?.sourceScoped && !bot?.remoteSource) {
return {
status: 'not_scoped',
route: null
}
}
const candidate = bot.route || {
connectionId: bot.connectionId,
mode: bot.connectionKind === 'local' ? 'local' : 'remote',
profile: bot.name,
targetProfile: bot.targetProfile || bot.name
}
const connectionId = String(candidate?.connectionId || '').trim()
const profile = String(candidate?.profile || bot?.name || '').trim() || 'default'
const targetProfile = String(candidate?.targetProfile || profile).trim() || profile
if (!connectionId) {
return {
status: 'owner_removed',
route: null,
profile
}
}
return {
status: 'resolved',
route: Object.freeze({
connectionId,
mode: candidate.mode === 'local' || connectionId === 'local' ? 'local' : 'remote',
profile,
targetProfile
})
}
}
/** Immutable owner descriptor for every source-scoped row. The active
* gateway is presentation state and is never consulted here. Strict: throws
* when the owning connection is gone -- correct for real dispatch
* (requestForBot, session creation, etc.), covered by
* remote-routing-races.test.mjs. Passive lookups (rendering, meta) must call
* resolveBotConnectionRoute() directly instead of catching this throw. */
export function botConnectionRoute(bot: Partial<RosterRow> | null | undefined): null | ProfileRoute {
const resolved = resolveBotConnectionRoute(bot)
if (resolved.status === 'owner_removed') {
throw new Error(`Bot ${resolved.profile} has no connection owner`)
}
return resolved.route
}
export function botWorkspaceOwnerKey(bot: RosterRow) {
// Render-reachable (sidebar sync, context menus): an orphaned row must
// yield a stable degraded key, never the dispatch throw.
const route = resolveBotConnectionRoute(bot).route
return `bot:${route ? botRouteKey(route) : String(bot?.name || 'default')}`
}
export function setBotsWorkspaceOwner(
ownerKey: string,
bot: null | RosterRow = null,
blockedMessage = 'Select a Bot or group first.'
) {
// Render-reachable (sidebar listener fires on visibility flips). An
// orphaned row degrades to the blocked target instead of throwing.
const route = bot ? resolveBotConnectionRoute(bot).route : null
const target: { kind: 'blocked'; message: string } | { kind: 'route'; route: ProfileRoute } = route
? {
kind: 'route',
route
}
: {
kind: 'blocked',
message: blockedMessage
}
host.setWorkspaceScope?.('bots', ownerKey, target)
}
export function backendTargetProfile(route: null | ProfileRoute | undefined, fallbackProfile = 'default') {
if (!route) {
return fallbackProfile
}
return route.targetProfile || route.profile
}
function rewriteCliProfileOperands(argv: string[], logical: string, target: string) {
const next = [...argv]
for (let index = 0; index < next.length; index += 1) {
if (next[index] === '--profile' && next[index + 1] === logical) {
next[index + 1] = target
index += 1
} else if (next[index] === `--profile=${logical}`) {
next[index] = `--profile=${target}`
}
}
const profileCommand = next.indexOf('profile')
const operand = profileCommand >= 0 ? profileCommand + 2 : -1
if (operand < next.length && next[operand] === logical) {
next[operand] = target
}
return next
}
function scopedBotParams(route: ProfileRoute, method: string, params: Record<string, unknown>) {
const logical = route.profile
const target = backendTargetProfile(route, logical)
let next = params
if (Object.prototype.hasOwnProperty.call(params, 'profile')) {
next = {
...next,
profile: target
}
}
if (method.startsWith('profiles.') && method !== 'profiles.create' && params.name === logical) {
next = {
...next,
name: target
}
}
if (params.clone_from === logical) {
next = {
...next,
clone_from: target
}
}
if (method === 'cli.exec' && Array.isArray(params.argv)) {
next = {
...next,
argv: rewriteCliProfileOperands(params.argv, logical, target)
}
}
return next
}
export function botBackendProfileScope(route: null | ProfileRoute | undefined, fallbackProfile = 'default') {
if (!route) {
return fallbackProfile
}
return {
connectionId: route.connectionId,
profile: backendTargetProfile(route, fallbackProfile)
}
}
/** Gateway RPC on the bot's OWN source. Source-scoped rows always use the
* explicit descriptor, including a registered local source. */
export async function requestForBot<T = unknown>(
bot: Partial<RosterRow> | null | undefined,
method: string,
params: Record<string, unknown> = {}
): Promise<T> {
const route = botConnectionRoute(bot)
if (route) {
if (typeof host.requestProfile !== 'function') {
throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`)
}
try {
return await host.requestProfile(route, method, scopedBotParams(route, method, params))
} catch (error) {
// React 19 formats query errors with `(error.name || '').trim()`. IPC /
// JSON-RPC rejections are often plain objects whose `name` is a number,
// which crashes the Routines pane and hides the original failure (#94471).
throw asRpcError(error, `Gateway request ${method} failed`)
}
}
try {
return await host.request(method, params)
} catch (error) {
throw asRpcError(error, `Gateway request ${method} failed`)
}
}
/** A rejection duck-typed across realms: an Error-like whose fields are only
* conventionally typed, so every read stays `unknown` until it is checked. */
export interface RpcErrorLike {
message?: unknown
name?: unknown
stack?: unknown
}
/** Coerce an IPC/JSON-RPC rejection into an Error with a string `name`.
*
* React Query stores whatever the queryFn throws. React 19 then formats it
* with `(e.name || '').trim()`, which throws TypeError when `name` is a
* number (JSON-RPC codes) or another non-string — the Routines pane crash
* in #94471. Real Error instances are returned as-is when already safe.
*/
function asRpcError(value: unknown, fallback: string): unknown {
// Duck-type across realms (plugin tests run the source in `vm`, and IPC
// can deliver Error-like objects whose prototype is not this realm's
// Error). React 19 only needs a string `name`. Never mutate the rejection:
// frozen/sealed objects make `name = 'Error'` a silent no-op in sloppy
// mode, so a non-string name always becomes a fresh Error with cause.
const isObject = value != null && typeof value === 'object'
const name = isObject ? (value as RpcErrorLike).name : undefined
const message = isObject ? (value as RpcErrorLike).message : undefined
const hasStringName = typeof name === 'string'
const hasStringMessage = typeof message === 'string'
const hasStack = isObject && typeof (value as RpcErrorLike).stack === 'string'
if (isObject && hasStringName && (hasStack || hasStringMessage)) {
return value
}
if (isObject) {
const text = hasStringMessage && String(message).trim() ? String(message) : fallback
const error = new Error(text)
error.cause = value
return error
}
return new Error(value == null || value === '' ? fallback : String(value))
}
// ── alias identity for connection rows (#89131) ─────────────────────────────
// A Desktop per-profile alias (profile `moxie` with a Cloud/URL/SSH override)
// routes to a remote backend's root profile: its route reads
// { connectionId: C, profile: 'moxie', targetProfile: 'default' }. Once that
// backend answers the roster itself, the row's identity is (C, 'default') —
// a DIFFERENT key than the alias meta (C::moxie / 'moxie') — so the friendly
// name fell off after source/session activation: the row regressed to the
// raw Cloud hostname, or to generic 'Hermes' in Cloud-only mode.
//
// aliasRouteIndex bridges the backend row identity back to its configured
// alias. It is keyed by (connectionId, targetProfile), so two same-named
// `default` rows on different connections can never share a title, and it
// fails closed when two aliases claim the same backend row (mirroring the
// fail-closed route resolution). This is the one sanctioned exception to
// "remote rows never borrow local meta": the alias IS the local identity of
// exactly this connection row, proven by the configured route — never by a
// bare name match.
interface AliasIdentity {
/** Both the source-qualified v2 key and the bare v1 name key. */
metaKeys: string[]
name: string
}
/** Keyed `${connectionId}::${targetProfile}`; null marks an ambiguous claim. */
let aliasRouteIndex = new Map<string, AliasIdentity | null>()
let aliasRouteEpoch = 0
/** Claim the next rebuild epoch BEFORE reading the route inventory, then hand
* the token to indexAliasRoutes. The read is async and the index is replaced
* wholesale, so two overlapping roster fetches can resolve out of order and
* the slower one's stale routes would win. */
export function beginAliasRouteIndex(): number {
aliasRouteEpoch += 1
return aliasRouteEpoch
}
/** Rebuild the alias index from the credential-free route inventory. Only
* genuine aliases (route.profile !== route.targetProfile) participate. A
* rebuild overtaken by a newer one drops its result. */
export function indexAliasRoutes(routes: ProfileRoute[], epoch = beginAliasRouteIndex()) {
if (epoch < aliasRouteEpoch) {
return
}
const next = new Map<string, AliasIdentity | null>()
for (const route of Array.isArray(routes) ? routes : []) {
const connectionId = String(route?.connectionId || '').trim()
const profile = String(route?.profile || '').trim()
const target = String(route?.targetProfile || '').trim()
if (!connectionId || !profile || !target || profile === target) {
continue
}
const key = `${connectionId}::${target}`
// Two aliases pointing at the same backend row are ambiguous — neither
// may claim the identity.
next.set(
key,
next.has(key)
? null
: {
name: profile,
// Alias meta can live under the source-qualified v2 key or the bare
// v1 name key (aliases predate the v2 migration on mixed setups).
metaKeys: [`${connectionId}::${profile}`, profile]
}
)
}
aliasRouteIndex = next
}
/** The configured alias identity claiming this roster row, or null. Matches
* strictly by (connectionId, backend target profile); the alias row itself
* keeps resolving its own meta directly. */
export function aliasIdentityFor(bot: Partial<RosterRow> | null | undefined): AliasIdentity | null {
if (!aliasRouteIndex.size) {
return null
}
const connectionId = String(
bot?.connectionId ||
bot?.route?.connectionId ||
// Unannotated rich rows (no host.agents on this build) still belong to
// the ACTIVE gateway — Cloud-only mode must resolve the alias too.
(!bot?.remoteSource && !bot?.sourceScoped ? host.state.connectionId?.get?.() || '' : '')
).trim()
if (!connectionId) {
return null
}
const target = String(bot?.targetProfile || bot?.route?.targetProfile || bot?.name || '').trim() || 'default'
const entry = aliasRouteIndex.get(`${connectionId}::${target}`) || null
return entry && entry.name !== String(bot?.name || '').trim() ? entry : null
}
// Bot metadata is scoped to the active gateway until the server exposes a
// union of rich profile rows. Never paint that metadata onto a thin row from
// another source: two `default` agents must not borrow each other's title,
// pin, avatar, group, unread state, or canonical-chat pointer. The ONE
// exception is a configured alias route claiming the row — see
// aliasRouteIndex above — which is connection-exact, never name-based.
export function botRosterMeta(bot: RosterRow, metaByName: Record<string, BotMeta>) {
if (bot?.sourceScoped || bot?.remoteSource) {
// Passive meta lookup: branch on the typed status instead of catching
// botConnectionRoute's throw, so an owner_removed row (e.g. a stale
// persisted group roster after its connection was deleted) reads as "no
// route" without masking an unrelated failure under the same catch.
const resolved = resolveBotConnectionRoute(bot)
const route = resolved.status === 'resolved' ? resolved.route : null
const direct = route ? metaByName?.[botRouteKey(route)] : null
if (direct) {
return direct
}
const alias = aliasIdentityFor(bot)
if (alias) {
for (const key of alias.metaKeys) {
if (metaByName?.[key]) {
return metaByName[key]
}
}
}
return direct
}
const own = metaByName?.[bot?.name]
if (own) {
return own
}
const alias = aliasIdentityFor(bot)
if (alias) {
for (const key of alias.metaKeys) {
if (metaByName?.[key]) {
return metaByName[key]
}
}
}
return own
}