Review round 2 on the host-backend collapse. Two defects the collapse introduced, both fixed on top of #118275's server-side `profile` plumbing (which this PR now requires: it must merge first). 1. The collapse ate the invariant "a route the server cannot profile-scope must keep a process-scoped backend". Routing case 6 sent EVERY local request at the shared primary, so a mutating handler that reads no `profile` executed against the primary's HERMES_HOME. Restored as a mechanical gate, not a route list: `unscopableMutatingRequest()` is true only when the method is unsafe AND `localPrimaryRequestScope()` returns null, and `sharesHostBackend()` refuses to collapse that request. The day a handler learns `profile`, it joins the scoped table and the gate stops seeing it. `ensureBackend()` now re-resolves with the ORIGINATING request, so its own route decision matches the caller's `resolveProfileApiRequest` one instead of silently collapsing a request routing deliberately kept pooled, and the spawn guard is told when a pooled spawn is the legitimate outcome. After #118275 the residual unscopable-mutating set is the host-path families (`POST /api/files/upload`, `DELETE /api/files/managed`) plus any unlisted mutating method (`POST /api/skills`). Session writes are scopable by `body.profile`, so they share the backend with their path left alone. 2. Events from non-primary local profiles arrived unowned. `sharedPrimaryRoute` returns the primary gateway without `createSecondary`, so nothing ran `stampSecondaryProfileOwner`, and use-gateway-boot captured the source profile ONCE in a `useEffect(..., [])` — every later profile's events were stamped with the BOOT-time profile. The profile is now read PER EVENT and the owner marker is applied whenever the active descriptor is `sharedPrimary`, so `runtimeSessionOwner()` resolves again and the live sessions/cron sync stops degrading to slow polling. The renderer is the right place: `_event_frame` carries no `profile`, so a server echo would be a cross-surface wire-contract change. Session PATCHes (rename/pin/archive/mark-read) now always name the owning profile in the body — falling back to the ACTIVE profile when the caller knows none — because the handler resolves its state.db from `body.profile` alone and no per-profile process remains to stand in for it. Also: the routing-table docstring entries 4-6 and the `hermes:api` comment now describe the behaviour they gate, and the spawn guard reads the same `profileRouteOptions()` the router does so the two cannot drift (it was dropping the per-profile SSH-override term). (cherry picked from commit 1e2354fb15866cc24d98e8e552a59240efe97a25)
91 lines
3.8 KiB
TypeScript
91 lines
3.8 KiB
TypeScript
// Multiplex-only, Desktop half: ONE `hermes serve` per HOST serves every
|
|
// profile, so a local profile never gets a backend process of its own.
|
|
//
|
|
// `backend-discovery.ts` + `host-backend-attach.ts` made the PRIMARY backend
|
|
// attach to a backend the host is already running. This module removes the
|
|
// other producer of `hermes serve` children: the per-profile backend pool.
|
|
// Every local profile now resolves onto the same host backend and carries its
|
|
// own `profile` on the wire — the server binds a SESSION to a profile home
|
|
// (`session.create {profile}` -> `profile_home`) and a sessionless RPC to the
|
|
// explicit `profile` argument (`tui_gateway/server.py::@_profile_scoped`), so
|
|
// one process genuinely serves N homes.
|
|
//
|
|
// Two things are deliberately NOT collapsed:
|
|
// * `HERMES_DESKTOP_ISOLATED_BACKEND=1` — the escape hatch that gives this
|
|
// app a private backend instead of the host's.
|
|
// * Remote / SSH / Cloud backends — a DIFFERENT host. Its ownership proof
|
|
// (`remote-lifecycle.ts` isolated_count === 1) is about that machine's
|
|
// process, not ours, and stays pooled.
|
|
//
|
|
// Pure and dependency-injected so the decision tests without Electron.
|
|
|
|
/** Everything the collapse decision needs about one profile's routing. */
|
|
export interface HostBackendCollapseOptions {
|
|
/** `HERMES_DESKTOP_ISOLATED_BACKEND=1`: this app wants its own backend. */
|
|
isolated?: boolean
|
|
/** This profile points at its own remote host (connection.json / SSH). */
|
|
profileRemoteOverride?: boolean
|
|
/** The primary profile's backend is itself a remote host. */
|
|
primaryRemoteActive?: boolean
|
|
/**
|
|
* This request MUTATES state the server cannot profile-scope
|
|
* (`connection-config.ts::unscopableMutatingRequest`). The pooled backend's
|
|
* own `HERMES_HOME` is then the only scope there is, so the collapse must
|
|
* not swallow it — and the spawn guard below must not refuse it.
|
|
*/
|
|
unscopableRequest?: boolean
|
|
}
|
|
|
|
/**
|
|
* True when a LOCAL profile shares the one host backend instead of spawning
|
|
* its own. False keeps the legacy pooled descriptor — which, for a remote
|
|
* route, never meant a local child in the first place.
|
|
*/
|
|
export function sharesHostBackend(opts: HostBackendCollapseOptions = {}): boolean {
|
|
if (opts.isolated || opts.unscopableRequest) {
|
|
return false
|
|
}
|
|
|
|
return !opts.profileRemoteOverride && !opts.primaryRemoteActive
|
|
}
|
|
|
|
/** Raised when something still tries to start a second local backend. */
|
|
export class SecondLocalBackendError extends Error {
|
|
readonly poolKey: string
|
|
|
|
constructor(poolKey: string) {
|
|
super(
|
|
`Refusing to start a second local Hermes backend for "${poolKey}": one backend serves every profile on this host. ` +
|
|
'Set HERMES_DESKTOP_ISOLATED_BACKEND=1 for a private backend.'
|
|
)
|
|
this.name = 'SecondLocalBackendError'
|
|
this.poolKey = poolKey
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The single enforcement point for "never spawn a second backend".
|
|
*
|
|
* Routing is what normally prevents a pooled local spawn; this guard sits at
|
|
* the one place a local `hermes serve` child is actually started, so a future
|
|
* caller that reaches it through a path routing does not cover fails loudly
|
|
* instead of quietly reintroducing a process per profile.
|
|
*/
|
|
export function assertNoSecondLocalBackend(poolKey: string, opts: HostBackendCollapseOptions = {}): void {
|
|
if (sharesHostBackend(opts)) {
|
|
throw new SecondLocalBackendError(poolKey)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A passive read (background tile reconcile, #103375) may only be served by a
|
|
* backend that already exists: it never cold-starts a child and never
|
|
* refreshes `lastActiveAt`, so an open-but-unviewed tile cannot keep the pool
|
|
* saturated. Callers treat the rejection as "nothing to refresh yet".
|
|
*/
|
|
export function assertNotPassiveSpawn(passive: boolean, poolKey: string): void {
|
|
if (passive) {
|
|
throw new Error(`Passive read: no warm backend for "${poolKey}"`)
|
|
}
|
|
}
|