feat(profiles): first-class profile switching — phase 0 baseline port (#820)
* feat(profiles): make profile switching actually scope the app (issue #781 follow-up) * fix(profiles): inherit connection token when switching to same-dashboard profile (issue #781 follow-up) * fix(chat): reset open session on profile switch + prefill login profile (desktop parity) * fix(ws): inject profile into profile-scoped RPC params so chat follows the selected profile * test(chat): de-poison SlashCommandDispatchRpcTest (drop static Dispatchers mock) mockkStatic(Dispatchers::class) + a stubbed Dispatchers.IO bleeds JVM-wide and breaks later classes in suite order (CI 2026-08-06: COMMAND_DISPATCH captures cross-wired → ComparisonFailure on lower stack PRs). ChatViewModel gains an injected ioDispatcher (38 sites); the slash test constructs the VM with the test dispatcher so the IO stub is unnecessary. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
e56d4c9bfa
commit
75ac0c4790
14 files changed
+579
-56
No files matched your search
@@ -73,6 +73,10 @@ object AuthManager {
|
||||
private val _tokenFlow = MutableStateFlow<String?>(null)
|
||||
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
||||
|
||||
/** Current profile scope (mirrors serverStore.selectedProfileId). */
|
||||
private val _selectedProfileFlow = MutableStateFlow<String?>(null)
|
||||
val selectedProfileFlow: StateFlow<String?> = _selectedProfileFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Initialise the encrypted preferences.
|
||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||
@@ -98,6 +102,7 @@ object AuthManager {
|
||||
appScope = scope
|
||||
val store = ServerStore(dataStore, scope)
|
||||
_serverStore = store
|
||||
_selectedProfileFlow.value = store.getLatestState().selectedProfileId
|
||||
|
||||
if (prefsDeferred == null) {
|
||||
prefsDeferred =
|
||||
@@ -332,6 +337,7 @@ object AuthManager {
|
||||
ActiveSessionHolder.clear()
|
||||
}
|
||||
serverStore.update { it.copy(selectedProfileId = id) }
|
||||
_selectedProfileFlow.value = id
|
||||
synchronized(this) {
|
||||
tokenInitialized = false
|
||||
}
|
||||
|
||||
@@ -19,19 +19,20 @@ import okhttp3.Response
|
||||
* - If the URL already carries an explicit `profile=` query, leave it
|
||||
* untouched — explicit beats global.
|
||||
* - Only the endpoint families below honor `?profile=` on the backend;
|
||||
* everything else (ops, pairing, cron, profiles themselves) is
|
||||
* machine-global or self-scoped and must NOT be rewritten.
|
||||
* everything else (ops, pairing, profiles themselves) is machine-global
|
||||
* or self-scoped and must NOT be rewritten. Cron, sessions and plugins
|
||||
* are profile-scoped on the backend (`/api/cron/jobs?profile=`,
|
||||
* `/api/sessions?profile=`, plugin REST) and ARE rewritten here so
|
||||
* switching profiles actually switches what the app shows.
|
||||
*/
|
||||
object ProfileScopeInterceptor : Interceptor {
|
||||
private val PROFILE_SCOPED_PREFIXES =
|
||||
listOf(
|
||||
"/api/status",
|
||||
"/api/gateway",
|
||||
"/api/analytics",
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
"/api/config",
|
||||
"/api/cron",
|
||||
"/api/env",
|
||||
"/api/gateway",
|
||||
"/api/mcp",
|
||||
"/api/messaging/platforms",
|
||||
"/api/messaging/telegram/onboarding",
|
||||
@@ -41,6 +42,11 @@ object ProfileScopeInterceptor : Interceptor {
|
||||
"/api/model/auxiliary",
|
||||
"/api/model/moa",
|
||||
"/api/model/options",
|
||||
"/api/plugins",
|
||||
"/api/sessions",
|
||||
"/api/skills",
|
||||
"/api/status",
|
||||
"/api/tools/toolsets",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -580,7 +580,13 @@ object HermesWsClient {
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
): String {
|
||||
val id = requestId.incrementAndGet().toString()
|
||||
val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() })
|
||||
val decoratedParams = WsProfileParams.decorate(method, params)
|
||||
val request =
|
||||
JsonRpcRequest(
|
||||
id = id,
|
||||
method = method,
|
||||
params = decoratedParams.mapValues { it.value.toJsonElement() },
|
||||
)
|
||||
val json = OkHttpProvider.json.encodeToString(request)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
|
||||
synchronized(outboundLock) {
|
||||
|
||||
@@ -72,4 +72,54 @@ object WsMethods {
|
||||
|
||||
/** Usage bars (token/cost breakdown the legacy credits block rendered). */
|
||||
const val USAGE_BARS = "usage.bars"
|
||||
|
||||
// ── Profile-scoped method set ────────────────────────────────────────
|
||||
// Methods that the TUI gateway resolves against the per-profile
|
||||
// HERMES_HOME / state.db when `params.profile` is present.
|
||||
//
|
||||
// Two categories on the server:
|
||||
// (a) @_profile_scoped (method_ctx.py) — binds HERMES_HOME via
|
||||
// ContextVar for the handler's lifetime.
|
||||
// (b) Manual params.get("profile") / _profile_db(params) — opens
|
||||
// the named profile's state.db explicitly.
|
||||
//
|
||||
// This set is the union of both. Only methods the mobile app
|
||||
// actually calls are listed (pet.* are included for completeness
|
||||
// since they read per-profile config). The set is intentionally an
|
||||
// explicit allowlist: unknown / new methods default to un-scoped,
|
||||
// which is safe — the server ignores an unrecognised `profile` key.
|
||||
|
||||
/**
|
||||
* Methods whose server handler reads `params["profile"]` to resolve the
|
||||
* correct per-profile data. Used by [WsProfileParams] to decide which
|
||||
* RPCs get the active profile injected automatically.
|
||||
*/
|
||||
val PROFILE_SCOPED_METHODS: Set<String> =
|
||||
setOf(
|
||||
// Category (b): manual params.get("profile") / _profile_db
|
||||
// Evidence: methods_session.py lines 42, 164, 232, 317, 845
|
||||
SESSION_CREATE, // session.create
|
||||
SESSION_LIST, // session.list
|
||||
SESSION_RESUME, // session.resume
|
||||
SESSION_DELETE, // session.delete
|
||||
SESSION_STATUS, // session.status
|
||||
// session.most_recent uses _profile_db(params) too, but
|
||||
// mobile doesn't call it; documented for completeness.
|
||||
// Category (a): @_profile_scoped decorator
|
||||
// Evidence: methods_session.py @_profile_scoped at each @method
|
||||
// Binds HERMES_HOME so config/skills/pets resolve to the
|
||||
// focused profile.
|
||||
"verification.status", // line 282
|
||||
"pet.info", // line 1253
|
||||
"pet.info.meta", // line 1279
|
||||
"pet.cells", // line 1302
|
||||
"pet.gallery", // line 1407
|
||||
"pet.select", // line 1490
|
||||
"pet.remove", // line 1517
|
||||
"pet.export", // line 1547
|
||||
"pet.rename", // line 1574
|
||||
"pet.thumb", // line 1612
|
||||
"pet.disable", // line 1647
|
||||
"pet.scale", // line 1661
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
|
||||
/**
|
||||
* WS-layer analog of [com.m57.hermescontrol.data.remote.ProfileScopeInterceptor].
|
||||
*
|
||||
* Injects `"profile"` into JSON-RPC `params` for profile-scoped methods so the
|
||||
* TUI gateway resolves the correct `HERMES_HOME` / `state.db` for the selected
|
||||
* profile. Without this, WS RPCs always hit the backend's **launch** profile
|
||||
* (usually "default"), even after the user switches profiles in the app.
|
||||
*
|
||||
* Rules (mirror the REST interceptor contract):
|
||||
* - If [AuthManager.getSelectedProfileId] is `null` → pass through unchanged
|
||||
* (legacy single-profile behavior).
|
||||
* - If the caller already supplied an explicit `"profile"` key in [params] →
|
||||
* explicit wins; do **not** overwrite.
|
||||
* - Only decorate methods in [WsMethods.PROFILE_SCOPED_METHODS]. Never touch
|
||||
* global / ops RPCs (`setup.*`, `gateway.*`, `auth.*`, ticket flows, etc.).
|
||||
*/
|
||||
object WsProfileParams {
|
||||
/**
|
||||
* Return a copy of [params] with `"profile"` injected when the active
|
||||
* profile is set and the [method] is profile-scoped. Returns [params]
|
||||
* unchanged (same reference) when no injection is needed.
|
||||
*/
|
||||
fun decorate(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Map<String, Any> {
|
||||
// No active profile → legacy single-profile behavior.
|
||||
val profile = AuthManager.getSelectedProfileId() ?: return params
|
||||
|
||||
// Only scoped methods get the profile key.
|
||||
if (method !in WsMethods.PROFILE_SCOPED_METHODS) return params
|
||||
|
||||
// Explicit profile param from the caller wins.
|
||||
if (params.containsKey("profile")) return params
|
||||
|
||||
// Inject profile into a new map (params may be immutable / emptyMap).
|
||||
return buildMap(params.size + 1) {
|
||||
putAll(params)
|
||||
put("profile", profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user