Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in new issue
Block a user