From 4f6b43484344b6b0c59fa123e016dc728f2e5c1f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:40:28 -0700 Subject: [PATCH 1/2] feat(sessions): one-shot single-match owner backfill for legacy NULL-profile rows (#94724) POST /api/sessions/owner-backfill stamps a store's own serving-profile identity onto its pre-#95407 'profile_name = NULL' session rows. Single match by construction (each profile's state.db belongs to exactly one profile), idempotent, one-shot-per-row, never overwrites a non-NULL owner, and reports the stamped count for logging. Refs #94724 --- hermes_cli/web_models.py | 11 ++ hermes_cli/web_routers/sessions.py | 46 ++++++ hermes_state.py | 39 +++++ .../hermes_cli/test_session_owner_backfill.py | 141 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 tests/hermes_cli/test_session_owner_backfill.py diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index 86f766ff20e4..fa5dd37243b0 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -345,6 +345,17 @@ class SessionRename(BaseModel): profile: Optional[str] = None +class SessionOwnerBackfill(BaseModel): + """Body for POST /api/sessions/owner-backfill (#94724 legacy migration). + + ``profile`` scopes WHICH profile's state.db is stamped (same semantics as + every other session route); the stamped value is always that store's own + serving-profile identity — the caller cannot inject an arbitrary owner. + """ + + profile: Optional[str] = None + + # --- from web_server.py (originally lines 12149-12174) --- class SessionPrune(BaseModel): diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index 32ab0ee8cba7..3289e6d52c51 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -26,6 +26,7 @@ from hermes_cli.web_models import ( BulkDeleteSessions, SessionImport, + SessionOwnerBackfill, SessionPrune, SessionRename, ) @@ -702,6 +703,51 @@ def _delete(): return await asyncio.to_thread(_delete) +@manage_router.post("/api/sessions/owner-backfill") +async def backfill_session_owner_profiles(body: SessionOwnerBackfill): + """Stamp legacy ``profile_name = NULL`` session rows with this store's own + serving-profile identity (#94724 legacy-session migration). + + Pre-#95407 rows never recorded an owning profile. That was fine while one + backend served everything, but a Desktop with registry topology (≥2 + registered connections) fails closed on unowned rows by design — leaving + every pre-campaign session unresumable with no migration path. Each + profile's ``state.db`` belongs to exactly one profile, so stamping that + store's own name is a single-match backfill, never a guess; the value + written is the SAME serving-profile identity the list endpoints already + stamp onto outgoing rows (``row_profile`` in ``get_sessions``). Idempotent + and one-shot-per-row: non-NULL owners are never overwritten and a second + call reports 0. + """ + profile_name: Optional[str] = None + if body.profile: + profile_name, _ = _cron_profile_home(body.profile) + stamp = profile_name or _cron_default_profile() + + def _backfill(): + db = _open_session_db_for_profile(body.profile, read_only=False) + try: + return db.backfill_null_session_profiles(stamp) + finally: + db.close() + + try: + stamped = await asyncio.to_thread(_backfill) + except HTTPException: + raise + except Exception: + _log.exception("POST /api/sessions/owner-backfill failed") + raise HTTPException(status_code=500, detail="Internal server error") + + if stamped: + _log.info( + "owner-backfill: stamped %d legacy NULL-profile session row(s) with profile %r", + stamped, + stamp, + ) + return {"ok": True, "stamped": stamped, "profile": stamp} + + @manage_router.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): """Update a session: rename, archive, hide, pin, and/or mark read/unread. diff --git a/hermes_state.py b/hermes_state.py index e5e35523fdaa..c53c23f1b6ff 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -9385,6 +9385,45 @@ def _do(conn): return self._execute_write(_do) > 0 + def backfill_null_session_profiles(self, profile_name: str) -> int: + """One-shot owner backfill for legacy pre-ownership session rows. + + Sessions created before the durable-ownership work (#95407 lineage) + carry ``profile_name = NULL``. On single-backend installs that was + harmless, but once a Desktop registers a second connection the + fail-closed owner ladder (which is correct for new sessions) can no + longer route those rows anywhere — every pre-campaign session becomes + unresumable after upgrade (#94724, field report). + + This store belongs to exactly one profile — the profile whose + ``state.db`` this is — so stamping its own name onto rows that never + recorded one is a single-match backfill, not a guess. Rules mirror the + ``create_session`` COALESCE contract: + + * only ``NULL``/empty ``profile_name`` rows are touched — a non-NULL + owner is NEVER overwritten; + * idempotent and one-shot-per-row: a second run matches zero rows. + + Returns the number of rows stamped (0 when nothing was legacy). + """ + stamp = (profile_name or "").strip() + if not stamp: + return 0 + + def _do(conn): + cursor = conn.execute( + """UPDATE sessions + SET profile_name = ? + WHERE profile_name IS NULL OR TRIM(profile_name) = ''""", + (stamp,), + ) + rowcount = cursor.rowcount + if rowcount is None or rowcount < 0: + rowcount = conn.execute("SELECT changes()").fetchone()[0] + return rowcount + + return int(self._execute_write(_do) or 0) + def set_session_archived(self, session_id: str, archived: bool) -> bool: """Archive or unarchive a session. diff --git a/tests/hermes_cli/test_session_owner_backfill.py b/tests/hermes_cli/test_session_owner_backfill.py new file mode 100644 index 000000000000..93f1f99864e0 --- /dev/null +++ b/tests/hermes_cli/test_session_owner_backfill.py @@ -0,0 +1,141 @@ +"""Legacy NULL-profile session owner backfill (#94724). + +Pre-#95407 session rows carry ``profile_name = NULL``. Once a Desktop has +registry topology (≥2 registered connections) the fail-closed owner ladder +can no longer route those rows, making every pre-campaign session +unresumable. POST /api/sessions/owner-backfill stamps each store's own +serving-profile identity onto its legacy rows — single-match by construction +(a profile's state.db belongs to exactly one profile), idempotent, and never +overwriting a non-NULL owner. +""" + +import sqlite3 + +import pytest + + +@pytest.fixture +def client(monkeypatch, _isolate_hermes_home): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + + client = TestClient(app) + client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + return client + + +def _seed(db_path, rows): + """Insert bare session rows the way a pre-ownership install left them.""" + from hermes_state import SessionDB + + db = SessionDB(db_path=db_path) + try: + for session_id, profile_name in rows: + db.create_session(session_id, source="cli", profile_name=profile_name) + db.append_message( + session_id, role="user", content=f"hello from {session_id}" + ) + # create_session backfills nothing here, but be explicit: force the + # legacy shape at the SQL level so the fixture cannot silently depend + # on create_session's own COALESCE behavior. + for session_id, profile_name in rows: + if profile_name is None: + db._conn.execute( + "UPDATE sessions SET profile_name = NULL WHERE id = ?", + (session_id,), + ) + db._conn.commit() + finally: + db.close() + + +def _profiles(db_path): + conn = sqlite3.connect(str(db_path)) + try: + return dict(conn.execute("SELECT id, profile_name FROM sessions").fetchall()) + finally: + conn.close() + + +def test_backfill_stamps_only_null_rows_and_is_idempotent(client): + from hermes_constants import get_hermes_home + + db_path = get_hermes_home() / "state.db" + _seed( + db_path, + [ + ("legacy-null-1", None), + ("legacy-null-2", None), + ("owned-other", "researcher"), + ], + ) + + resp = client.post("/api/sessions/owner-backfill", json={}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["ok"] is True + # Exactly the two legacy rows were stamped; the count is reported so the + # caller can log it. + assert body["stamped"] == 2 + assert body["profile"] == "default" + + stamped = _profiles(db_path) + assert stamped["legacy-null-1"] == "default" + assert stamped["legacy-null-2"] == "default" + # Fail-closed contract: a non-NULL owner is NEVER overwritten, even when + # it names a different profile than the serving store. + assert stamped["owned-other"] == "researcher" + + # One-shot-per-row: a second run finds nothing left to stamp and the rows + # are byte-identical. + resp2 = client.post("/api/sessions/owner-backfill", json={}) + assert resp2.status_code == 200 + assert resp2.json()["stamped"] == 0 + assert _profiles(db_path) == stamped + + +def test_backfilled_rows_circulate_owned_on_the_list_endpoint(client): + """After the backfill, the durable stamp (not just the per-response + serving-profile decoration) owns the rows: the raw DB column is non-NULL, + which is what survives into any other consumer of state.db.""" + from hermes_constants import get_hermes_home + + db_path = get_hermes_home() / "state.db" + _seed(db_path, [("legacy-null-3", None)]) + + assert _profiles(db_path)["legacy-null-3"] is None + + resp = client.post("/api/sessions/owner-backfill", json={}) + assert resp.status_code == 200 + assert resp.json()["stamped"] == 1 + + listed = client.get("/api/sessions?limit=50&offset=0").json()["sessions"] + row = next(s for s in listed if s["id"] == "legacy-null-3") + assert row["profile"] == "default" + assert _profiles(db_path)["legacy-null-3"] == "default" + + +def test_backfill_treats_empty_string_profile_as_legacy(client): + """TRIM('') rows are the same stranded class as NULL — stamp them too.""" + from hermes_constants import get_hermes_home + + db_path = get_hermes_home() / "state.db" + _seed(db_path, [("legacy-empty", None)]) + + conn = sqlite3.connect(str(db_path)) + conn.execute("UPDATE sessions SET profile_name = ' ' WHERE id = 'legacy-empty'") + conn.commit() + conn.close() + + resp = client.post("/api/sessions/owner-backfill", json={}) + assert resp.status_code == 200 + assert resp.json()["stamped"] == 1 + assert _profiles(get_hermes_home() / "state.db")["legacy-empty"] == "default" From dabb369fd8c6b6b6ebfaa67bbfb95eeddb19b56b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:40:28 -0700 Subject: [PATCH 2/2] feat(desktop): read-only stored-transcript resume + legacy owner-backfill trigger (#94724) The fail-closed owner ladder (#95407) is correct for new sessions, but legacy unowned rows on registry-topology installs dead-ended in SessionOwnerResolutionError (reporter's Error B) with their transcripts fully intact in state.db. - resolveLegacyOwnerBackfillScope: pick the single-match store for the server-side owner backfill at enumeration time (serving registered connection / primary pool); fail closed on multi-candidate topologies. - maybeBackfillLegacySessionOwners: one-shot per scope per renderer, fire-and-forget from the #95407 stamp path, logs the stamped count. - Read-only stored-transcript resume: when session.resume fails closed, fetch the transcript over id-only REST (ambient first, then registered backends, read-only probes only) and open the session as a read-only transcript instead of dead-ending; sends are refused with a notice and a later successful live resume clears the latch. Wired into the main pane resume recovery and the session-tile delegate (which now runs the same fail-closed owner gate as the RPC dispatcher). Refs #94724 --- apps/desktop/src/api/sessions.ts | 44 ++++++ .../hooks/use-session-tile-delegate.ts | 88 +++++++++-- .../hooks/use-prompt-actions/submit.ts | 11 ++ .../hooks/use-session-actions/index.ts | 55 ++++++- apps/desktop/src/i18n/ar.ts | 3 + apps/desktop/src/i18n/en.ts | 4 + apps/desktop/src/i18n/ja.ts | 3 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh-hant.ts | 3 + apps/desktop/src/i18n/zh.ts | 3 + .../lib/legacy-session-owner-backfill.test.ts | 79 ++++++++++ .../src/lib/legacy-session-owner-backfill.ts | 93 ++++++++++++ apps/desktop/src/lib/session-owner-stamp.ts | 67 +++++++++ .../src/store/read-only-transcript.test.ts | 141 ++++++++++++++++++ .../desktop/src/store/read-only-transcript.ts | 114 ++++++++++++++ 15 files changed, 699 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/lib/legacy-session-owner-backfill.test.ts create mode 100644 apps/desktop/src/lib/legacy-session-owner-backfill.ts create mode 100644 apps/desktop/src/store/read-only-transcript.test.ts create mode 100644 apps/desktop/src/store/read-only-transcript.ts diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts index 3e51132d06fd..c5f559c07afb 100644 --- a/apps/desktop/src/api/sessions.ts +++ b/apps/desktop/src/api/sessions.ts @@ -1,4 +1,5 @@ import { isMissingRestEndpoint } from '@/lib/gateway-rpc' +import { maybeBackfillLegacySessionOwners } from '@/lib/legacy-session-owner-backfill' import { stampRowsWithOwningConnection } from '@/lib/session-owner-stamp' import { recordTranscriptTail } from '@/store/transcript-tail' import type { @@ -42,6 +43,12 @@ function sessionScopeQuery(scope?: ProfileScope): string { * write shape for connection_id on backend-returned rows. */ function stampActiveConnectionOwner(sessions: SessionInfo[]): SessionInfo[] { + // Durable half of the same ownership contract (#94724): enumeration under + // registry topology triggers the one-shot server-side owner backfill for + // the serving store when its owner is a single match. Fire-and-forget; + // idempotent server-side; never blocks or fails the list that triggered it. + maybeBackfillLegacySessionOwners() + return stampRowsWithOwningConnection(sessions, getApiRequestConnection()) } @@ -417,6 +424,43 @@ export function getLatestSessionMessages(id: string, profile?: ProfileScope): Pr }) } +/** + * READ-ONLY stored-transcript lookup that never routes a live session + * (#94724 no-owner recovery). Tries the ambient/primary store first, then + * probes every registered NON-local connection by id — a REST read of a + * backend's own state.db is side-effect free (a miss is a plain 404, no + * session is minted or resumed anywhere), so probing across backends is safe + * where live routing would be a guess. Returns null when no reachable + * backend holds the transcript. + */ +export async function fetchStoredTranscriptAcrossBackends(id: string): Promise { + try { + return await getLatestSessionMessages(id) + } catch { + // Not on the ambient store — probe the registered backends below. + } + + const { $connectionsRegistry } = await import('@/store/connection-registry-state') + + const connections = ($connectionsRegistry.get()?.connections ?? []) as Array<{ id?: string }> + + for (const connection of connections) { + const connectionId = connection.id?.trim() + + if (!connectionId || connectionId === 'local' || connectionId === getApiRequestConnection()) { + continue + } + + try { + return await getLatestSessionMessages(id, { connectionId, profile: 'default' }) + } catch { + // Not on this backend (or it is unreachable); try the next. + } + } + + return null +} + /** * One page of messages OLDER than the `offset` newest rows. * diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index b2524e7919de..b0ebd3ab62dc 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -1,8 +1,12 @@ import { useEffect } from 'react' -import { getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' +import { fetchStoredTranscriptAcrossBackends, getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' +import { translateNow } from '@/i18n/runtime' import { toChatMessages } from '@/lib/chat-messages' +import { notify } from '@/store/notifications' +import { isReadOnlyRuntimeId, readOnlyRuntimeIdFor, resumeWithStoredTranscriptFallback } from '@/store/read-only-transcript' import { knownSessionOwner, ownerLookupSessionRows } from '@/store/session' +import { assertSessionOwnerResolved } from '@/store/session-owner-resolution' import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router' import { publishSessionState, sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states' import type { SessionResumeResponse } from '@/types/hermes' @@ -138,6 +142,11 @@ export function useSessionTileDelegate({ return true }, interruptSession: async runtimeId => { + // Read-only stored-transcript tiles have no live turn to interrupt. + if (isReadOnlyRuntimeId(runtimeId)) { + return + } + // Same cooldown as the primary chat's Stop (#83855): the gateway may // still be winding down after this interrupt, so a quick edit/resend // on the tile must go interrupt-first even though busy already reads @@ -193,17 +202,65 @@ export function useSessionTileDelegate({ ? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile } : owner - const [prefetch, resumed] = await Promise.all([ - getLatestSessionMessages(storedSessionId, restScope).catch(() => null), - singleFlightSessionResume(storedSessionId, () => - requestForSessionProfile(owner, requestGateway, 'session.resume', { - session_id: storedSessionId, - cols: 96, - omit_messages: true, - ...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {}) - }) + const prefetchPromise = getLatestSessionMessages(storedSessionId, restScope).catch(() => null) + + // #94724 no-owner recovery: dispatching the resume through the same + // fail-closed gate as the window's RPC dispatcher keeps an unknown + // owner off the ambient socket, and the wrapper opens the stored + // transcript read-only instead of dead-ending the tile — the id-only + // REST read routes no live session at all. + const outcome = await resumeWithStoredTranscriptFallback( + storedSessionId, + () => { + assertSessionOwnerResolved(owner, { method: 'session.resume', sessionId: storedSessionId }) + + return singleFlightSessionResume(storedSessionId, () => + requestForSessionProfile(owner, requestGateway, 'session.resume', { + session_id: storedSessionId, + cols: 96, + omit_messages: true, + ...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {}) + }) + ) + }, + async () => { + const stored = (await prefetchPromise) ?? (await fetchStoredTranscriptAcrossBackends(storedSessionId)) + + if (!stored) { + throw new Error('stored transcript unavailable on every reachable backend') + } + + return stored + } + ) + + const prefetch = await prefetchPromise + + if (outcome.mode === 'read-only') { + const readOnlyId = readOnlyRuntimeIdFor(storedSessionId) + + updateSessionState( + readOnlyId, + state => ({ + ...state, + busy: false, + awaitingResponse: false, + messages: + state.messages.length > 0 ? state.messages : toChatMessages(outcome.transcript?.messages ?? []) + }), + storedSessionId ) - ]) + + notify({ + kind: 'info', + title: translateNow('desktop.readOnlyTranscriptTitle'), + message: translateNow('desktop.readOnlyTranscriptBody') + }) + + return readOnlyId + } + + const resumed = outcome.resumed const runtimeId = resumed?.session_id @@ -233,6 +290,15 @@ export function useSessionTileDelegate({ return runtimeId }, submitToSession: async (runtimeId, text) => { + // A read-only stored-transcript tile has no live runtime to submit + // into (#94724). Refuse with the explanation instead of minting a + // misrouted prompt on a backend that never owned the session. + if (isReadOnlyRuntimeId(runtimeId)) { + notify({ kind: 'info', message: translateNow('desktop.readOnlyTranscriptSendBlocked') }) + + return + } + const storedSessionId = storedSessionIdForRuntime(runtimeId) const routedRequest = storedSessionId diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 4a0ad3b1464d..e3745a4fe9fe 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -21,6 +21,7 @@ import { import { $hudMode } from '@/store/hud' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { consumePendingCredentialWarning, requestDesktopOnboarding } from '@/store/onboarding' +import { isStoredTranscriptReadOnly } from '@/store/read-only-transcript' import { $sessions, resolveComposerSessionKey, @@ -209,6 +210,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // to another chat. let targetStoredSessionId = options?.storedSessionId ?? selectedStoredSessionIdRef.current + // A read-only stored-transcript open (#94724: owner unresolvable under + // registry topology) has no routable live runtime — refuse the send + // with the explanation rather than minting a prompt on a backend that + // never owned the session. + if (isStoredTranscriptReadOnly(targetStoredSessionId)) { + notify({ kind: 'info', message: copy.readOnlyTranscriptSendBlocked }) + + return false + } + let targetStartedInCurrentView = !targetStoredSessionId || targetStoredSessionId === selectedStoredSessionIdRef.current diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 141b17e71fba..a7b3446c38ed 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -6,7 +6,13 @@ import { NO_PROJECT_ID } from '@/app/chat/sidebar/projects/workspace-groups' import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill' import { revealTreePane } from '@/components/pane-shell/tree/store' import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' -import { deleteSession, getAllSessionMessages, getLatestSessionMessages, setSessionArchived } from '@/hermes' +import { + deleteSession, + fetchStoredTranscriptAcrossBackends, + getAllSessionMessages, + getLatestSessionMessages, + setSessionArchived +} from '@/hermes' import { useI18n } from '@/i18n' import { type ChatMessage, @@ -52,6 +58,7 @@ import { untombstoneSessions } from '@/store/projects' import { setApprovalRequest } from '@/store/prompts' +import { clearStoredTranscriptReadOnly, markStoredTranscriptReadOnly } from '@/store/read-only-transcript' import { $activeSessionStoredIdRotation, $connection, @@ -90,6 +97,7 @@ import { setWorkspaceCwdOwner, setYoloActive } from '@/store/session' +import { isSessionOwnerResolutionError } from '@/store/session-owner-resolution' import { requestForSessionProfile, type SessionOwnerScope, @@ -1583,6 +1591,10 @@ export function useSessionActions({ setActiveSessionId(resumed.session_id) activeSessionIdRef.current = resumed.session_id + // A live resume proves the owner routed — retire any read-only latch + // a previous no-owner open left behind (#94724: the backfill stamped + // the row, or a topology change made the owner resolvable again). + clearStoredTranscriptReadOnly(storedSessionId) const pendingApproval = restorePendingApproval(resumed, resumed.session_id) const pendingClarifyState = restorePendingClarifyFromSnapshot(resumed, resumed.session_id, resumeStartedAt) const pendingClarify = pendingClarifyState.request @@ -1723,6 +1735,47 @@ export function useSessionActions({ return } + // #94724 no-owner recovery: the owner ladder failed closed — which is + // CORRECT under registry topology — but the stored transcript may be + // fully intact in some backend's state.db. If the ambient REST + // fallback above didn't already paint it, probe the registered + // backends READ-ONLY (id-only GET; no live session is routed or + // minted anywhere). When history is reachable, open the session + // read-only instead of dead-ending on the resolution error: writes + // stay blocked, and a later resume (after the single-match owner + // backfill stamps the row) upgrades it back to a live session. + if (isSessionOwnerResolutionError(err)) { + let painted = !fallbackError && viewMessagesForReconcile().length > 0 + + if (!painted) { + const stored = await fetchStoredTranscriptAcrossBackends(storedSessionId).catch(() => null) + + if (!isCurrentResume()) { + return + } + + if (stored && stored.messages.length > 0) { + const previousMessages = resumedSameSelectedSession + ? preserveLocalPendingTurnMessages(viewMessagesForReconcile(), resumeStartMessages) + : viewMessagesForReconcile() + + setMessages(reconcileAuthoritativeMessages(stored.messages, previousMessages)) + painted = true + } + } + + if (painted) { + markStoredTranscriptReadOnly(storedSessionId) + notify({ + kind: 'info', + title: copy.readOnlyTranscriptTitle, + message: copy.readOnlyTranscriptBody + }) + + return + } + } + // The session is genuinely gone (deleted, or a stale id from a wiped / // rotated backend): the resume RPC and the authoritative REST transcript // both 404. There's nothing to recover — silently drop to a fresh draft diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 57a2c314491d..ac81f7821c1e 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2779,6 +2779,9 @@ export const ar = defineLocale({ editFailed: 'فشل التحرير', editTurnUnavailable: 'هذه الجولة لم تعد في سجل الخادم (ربما أزيلت بالضغط).', resumeFailed: 'فشل الاستئناف', + readOnlyTranscriptTitle: 'فُتحت للقراءة فقط', + readOnlyTranscriptBody: 'لا يوجد بعد خادم متصل يملك هذه المحادثة القديمة، لذا فُتحت كنصّ محفوظ للقراءة فقط. السجل سليم؛ الإرسال معطّل حتى يتبنّاها خادم.', + readOnlyTranscriptSendBlocked: 'هذه المحادثة مفتوحة كنصّ محفوظ للقراءة فقط — الإرسال معطّل.', resumeStrandedTitle: 'تعذّر تحميل هذه الجلسة', resumeStrandedBody: 'فشل الاتصال بهذه الجلسة وتوقفت إعادة المحاولة التلقائية. تأكد من تشغيل البوابة، ثم حاول مجددا.', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index b88e0e7f2b1f..a3c7fb1ce0ae 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3408,6 +3408,10 @@ export const en: Translations = { editFailed: 'Edit failed', editTurnUnavailable: 'This turn is no longer in server history (it may have been compressed away).', resumeFailed: 'Resume failed', + readOnlyTranscriptTitle: 'Opened read-only', + readOnlyTranscriptBody: + 'No connected backend claims this older chat yet, so it opened as a read-only transcript. Its history is intact; sending is disabled until a backend claims it.', + readOnlyTranscriptSendBlocked: 'This chat is open as a read-only transcript — sending is disabled.', resumeStrandedTitle: "Couldn't load this session", resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 9dede3d19ef9..dba0dcb5fdd4 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3030,6 +3030,9 @@ export const ja = defineLocale({ editFailed: '編集に失敗しました', editTurnUnavailable: 'このターンはサーバー履歴にありません(圧縮で削除された可能性があります)。', resumeFailed: '再開に失敗しました', + readOnlyTranscriptTitle: '読み取り専用で開きました', + readOnlyTranscriptBody: 'この古いチャットを所有するバックエンドがまだ接続されていないため、読み取り専用のトランスクリプトとして開きました。履歴は無事です。バックエンドが所有を認識するまで送信は無効です。', + readOnlyTranscriptSendBlocked: 'このチャットは読み取り専用トランスクリプトとして開いています。送信は無効です。', resumeStrandedTitle: 'このセッションを読み込めませんでした', resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 839190d470ef..4c2df3174084 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2928,6 +2928,9 @@ export interface Translations { editFailed: string editTurnUnavailable: string resumeFailed: string + readOnlyTranscriptTitle: string + readOnlyTranscriptBody: string + readOnlyTranscriptSendBlocked: string resumeStrandedTitle: string resumeStrandedBody: string resumeRetry: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 76013770894c..6fa49ea3e6f8 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2905,6 +2905,9 @@ export const zhHant = defineLocale({ editFailed: '編輯失敗', editTurnUnavailable: '此回合已不在伺服器歷史中(可能已被壓縮移除)。', resumeFailed: '繼續失敗', + readOnlyTranscriptTitle: '已以唯讀方式開啟', + readOnlyTranscriptBody: '尚無已連線的後端認領這個較早的對話,因此它以唯讀逐字稿方式開啟。歷史紀錄完好;在有後端認領之前無法傳送訊息。', + readOnlyTranscriptSendBlocked: '此對話目前以唯讀逐字稿方式開啟——傳送已停用。', resumeStrandedTitle: '無法載入此工作階段', resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。', resumeRetry: '重試', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1bbe575888a3..e86c04e74472 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3553,6 +3553,9 @@ export const zh: Translations = { editFailed: '编辑失败', editTurnUnavailable: '此回合已不在服务器历史中(可能已被压缩移除)。', resumeFailed: '恢复失败', + readOnlyTranscriptTitle: '已以只读方式打开', + readOnlyTranscriptBody: '尚无已连接的后端认领这个较早的会话,因此它以只读记录方式打开。历史记录完好;在有后端认领之前无法发送消息。', + readOnlyTranscriptSendBlocked: '该会话目前以只读记录方式打开——发送已禁用。', resumeStrandedTitle: '无法加载此会话', resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。', resumeRetry: '重试', diff --git a/apps/desktop/src/lib/legacy-session-owner-backfill.test.ts b/apps/desktop/src/lib/legacy-session-owner-backfill.test.ts new file mode 100644 index 000000000000..c6d37fc02cc7 --- /dev/null +++ b/apps/desktop/src/lib/legacy-session-owner-backfill.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { resolveLegacyOwnerBackfillScope } from './session-owner-stamp' + +describe('resolveLegacyOwnerBackfillScope (#94724 single-match owner backfill)', () => { + it('targets the serving registered connection (the backend that serves a page owns its rows)', () => { + const scope = resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: true, + registryConnectionIds: ['local', 'gw-b', 'gw-c'], + servingConnectionId: 'gw-b' + }) + + expect(scope).toEqual({ connectionId: 'gw-b', profile: null }) + }) + + it('targets the primary store when the primary pool is serving', () => { + // The primary's own per-profile store is a single known owner even with + // several registered connections — its rows can live nowhere else. + const scope = resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: true, + registryConnectionIds: ['gw-b', 'gw-c'], + servingConnectionId: null + }) + + expect(scope).toEqual({ connectionId: null, profile: null }) + }) + + it("treats the explicit 'local' source as the primary store", () => { + expect( + resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: true, + registryConnectionIds: ['gw-b'], + servingConnectionId: 'local' + }) + ).toEqual({ connectionId: null, profile: null }) + }) + + it('fails closed when the serving source is unknown and several backends could own the store', () => { + // Multi-candidate: never guess. The rows stay NULL and the read-only + // stored-transcript path keeps their history reachable. + expect( + resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: true, + registryConnectionIds: ['gw-b', 'gw-c'], + servingConnectionId: undefined + }) + ).toBeNull() + }) + + it('resolves the single registered backend when the serving source is unknown but only one candidate exists', () => { + expect( + resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: true, + registryConnectionIds: ['local', 'gw-b'], + servingConnectionId: undefined + }) + ).toEqual({ connectionId: 'gw-b', profile: null }) + }) + + it('fails closed when the serving connection is not in the registry', () => { + expect( + resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: true, + registryConnectionIds: ['gw-b'], + servingConnectionId: 'gw-unregistered' + }) + ).toBeNull() + }) + + it('does nothing without registry topology (legacy single-backend installs are unaffected)', () => { + expect( + resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: false, + registryConnectionIds: [], + servingConnectionId: null + }) + ).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/legacy-session-owner-backfill.ts b/apps/desktop/src/lib/legacy-session-owner-backfill.ts new file mode 100644 index 000000000000..c839fd751ee3 --- /dev/null +++ b/apps/desktop/src/lib/legacy-session-owner-backfill.ts @@ -0,0 +1,93 @@ +/** + * One-shot durable owner backfill for legacy NULL-profile session rows + * (#94724 legacy-session migration). + * + * The #95407 durable-ownership work made owner resolution fail closed under + * registry topology, but shipped no migration for rows minted BEFORE + * ownership existed (`profile_name = NULL` in state.db). On any install with + * ≥2 registered connections every one of those rows became unresumable — the + * reporter's install had 1,120 of 1,122 rows stranded with their transcripts + * fully intact. + * + * This module is the desktop half of the migration: at enumeration time, + * when a served page contains legacy unowned rows AND the owner is a single + * match (see `resolveLegacyOwnerBackfillScope`), ask that backend — over the + * existing session-update REST surface — to stamp its own legacy rows with + * its own serving-profile identity. Ambiguous topologies are left alone + * (fail closed); the read-only stored-transcript path keeps their history + * reachable. + * + * The request is one-shot per (connection, profile) scope per renderer: + * the server side is idempotent and never overwrites a non-NULL owner, so a + * repeat is harmless but pointless. A transport failure re-arms the scope so + * the next refresh retries; a backend without the endpoint (version skew) + * stays armed-off for this renderer lifetime. + */ +import { getApiRequestConnection, hermesApi } from '@/api/client' +import { isMissingRestEndpoint } from '@/lib/gateway-rpc' +import { resolveLegacyOwnerBackfillScope } from '@/lib/session-owner-stamp' +import { $connectionsRegistry, hasRegistryTopology } from '@/store/connection-registry-state' + +const attemptedScopes = new Set() + +/** Test seam: forget which scopes were already backfilled this renderer. */ +export function resetLegacyOwnerBackfillAttempts(): void { + attemptedScopes.clear() +} + +function scopeKey(connectionId: null | string, profile: null | string): string { + return `${connectionId ?? 'local'}::${profile ?? ''}` +} + +/** + * Fire-and-forget: enumeration paths call this on every served page. It is + * synchronous-cheap on the no-op paths (no registry topology, scope already + * attempted) and never blocks or fails the list request that triggered it. + */ +export function maybeBackfillLegacySessionOwners(): void { + const scope = resolveLegacyOwnerBackfillScope({ + hasRegistryTopology: hasRegistryTopology(), + registryConnectionIds: ($connectionsRegistry.get()?.connections ?? []).map( + (connection: { id: string }) => connection.id + ), + servingConnectionId: getApiRequestConnection() + }) + + if (!scope) { + return + } + + const key = scopeKey(scope.connectionId, scope.profile) + + if (attemptedScopes.has(key)) { + return + } + + attemptedScopes.add(key) + + void hermesApi<{ ok: boolean; profile: string; stamped: number }>({ + ...(scope.connectionId ? { connectionId: scope.connectionId } : {}), + ...(scope.profile ? { profile: scope.profile } : {}), + path: '/api/sessions/owner-backfill', + method: 'POST', + body: scope.profile ? { profile: scope.profile } : {} + }) + .then(result => { + if (result.stamped > 0) { + console.info( + `[legacy-session-owner-backfill] stamped ${result.stamped} legacy session row(s) ` + + `with profile "${result.profile}" on ${scope.connectionId ?? 'the primary backend'}` + ) + } + }) + .catch(error => { + // Version skew: this backend predates the endpoint. Keep the scope + // marked so we don't re-probe a known-dead route every refresh. + if (isMissingRestEndpoint(error)) { + return + } + + // Transient failure: re-arm so the next enumeration retries. + attemptedScopes.delete(key) + }) +} diff --git a/apps/desktop/src/lib/session-owner-stamp.ts b/apps/desktop/src/lib/session-owner-stamp.ts index ca7a8aec120c..04839e0d48ad 100644 --- a/apps/desktop/src/lib/session-owner-stamp.ts +++ b/apps/desktop/src/lib/session-owner-stamp.ts @@ -29,3 +29,70 @@ export function stampRowsWithOwningConnection( return sessions.map(session => (session.connection_id?.trim() ? session : { ...session, connection_id: owner })) } + +/** A durable backfill target: which backend store to stamp, expressed in the + * same scope vocabulary every session API call uses. `connectionId === null` + * means the primary/local backend's own store. */ +export interface LegacyOwnerBackfillScope { + connectionId: null | string + profile: null | string +} + +export interface LegacyOwnerBackfillTopology { + /** Registry topology present (published registry, or the modern bridge). */ + hasRegistryTopology: boolean + /** Non-local registered connection ids from the registry snapshot. */ + registryConnectionIds: string[] + /** The source that served this page of rows: a registered connection id, + * `'local'`/`null` for the primary pool, `undefined` when the caller + * cannot name the serving source at all. */ + servingConnectionId: null | string | undefined +} + +/** + * Decide whether enumeration under the CURRENT topology warrants the + * one-shot legacy owner backfill (#94724), and against WHICH store — the + * durable counterpart of `stampRowsWithOwningConnection`'s in-memory stamp. + * + * Pre-#95407 rows carry `profile_name = NULL` in state.db. The list + * endpoints decorate outgoing rows with their serving profile, so the + * Desktop cannot see which rows are legacy from a page — but the SERVER + * knows exactly, and its backfill is idempotent and one-shot-per-row. The + * desktop's job is only to pick the store whose owner is a single match: + * the backend that serves an enumeration owns every row it serves. + * + * Fail-closed rules (returns null — rows stay NULL, and the read-only + * stored-transcript path keeps their history reachable): + * - no registry topology (nothing is broken; ambient owns every session); + * - the serving source is a connection the registry does not know; + * - the serving source cannot be named AND more than one registered + * backend could own the store (multi-candidate — never guess). + */ +export function resolveLegacyOwnerBackfillScope( + topology: LegacyOwnerBackfillTopology +): LegacyOwnerBackfillScope | null { + if (!topology.hasRegistryTopology) { + return null + } + + const serving = topology.servingConnectionId + + if (serving === null || serving?.trim() === 'local') { + // Primary pool rows live in the primary's own per-profile store — a + // single known owner, stamped as that store's own serving profile. + return { connectionId: null, profile: null } + } + + const servingId = serving?.trim() ?? '' + const registered = topology.registryConnectionIds.map(id => id.trim()).filter(id => id && id !== 'local') + + if (servingId) { + // The backend that served the rows owns them. Only a REGISTERED source + // is a durable owner; an unknown source id cannot be trusted to survive. + return registered.includes(servingId) ? { connectionId: servingId, profile: null } : null + } + + // Serving source unknown: single-match only when exactly one registered + // backend exists. Two or more candidates would make the stamp a guess. + return registered.length === 1 ? { connectionId: registered[0], profile: null } : null +} diff --git a/apps/desktop/src/store/read-only-transcript.test.ts b/apps/desktop/src/store/read-only-transcript.test.ts new file mode 100644 index 000000000000..11acfd0f2e99 --- /dev/null +++ b/apps/desktop/src/store/read-only-transcript.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connectionsRegistry } from './connections' +import { $profiles } from './profile' +import { + $readOnlyStoredTranscripts, + clearStoredTranscriptReadOnly, + isReadOnlyRuntimeId, + isStoredTranscriptReadOnly, + markStoredTranscriptReadOnly, + readOnlyRuntimeIdFor, + resumeWithStoredTranscriptFallback +} from './read-only-transcript' +import { assertSessionOwnerResolved } from './session-owner-resolution' + +const registry = (...ids: string[]) => + ({ + connections: ids.map(id => ({ id })), + lastUsed: ids[0] ?? null, + launchMode: 'primary', + primary: ids[0] ?? null + }) as never + +beforeEach(() => { + $connectionsRegistry.set(null) + $profiles.set([]) + $readOnlyStoredTranscripts.set(new Set()) +}) + +afterEach(() => { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + +describe('read-only stored-transcript resume (#94724 no-owner recovery)', () => { + it('opens the stored transcript read-only when the owner fails closed on a 2-connection topology', async () => { + // The reporter's shape: registry topology with two registered + // connections, a legacy NULL-owner row — the production gate throws + // SessionOwnerResolutionError for session.resume (Error B). + $connectionsRegistry.set(registry('gw-a', 'gw-b')) + $profiles.set([{ name: 'default' }, { name: 'researcher' }] as never) + + const gatewayDispatch = vi.fn() + const transcript = { messages: [{ content: 'intact history', role: 'user' }], session_id: 'legacy-1' } + + const outcome = await resumeWithStoredTranscriptFallback( + 'legacy-1', + async () => { + // The REAL fail-closed gate, not a re-implementation: an unknown + // owner under registry topology throws before any dispatch. + assertSessionOwnerResolved(undefined, { method: 'session.resume', sessionId: 'legacy-1' }) + gatewayDispatch() + + return { session_id: 'runtime-1' } + }, + async () => transcript + ) + + expect(outcome.mode).toBe('read-only') + + if (outcome.mode === 'read-only') { + expect(outcome.transcript).toBe(transcript) + expect(outcome.error.name).toBe('SessionOwnerResolutionError') + } + + // The whole point of the recovery path: NO gateway routing happened. + expect(gatewayDispatch).not.toHaveBeenCalled() + expect(isStoredTranscriptReadOnly('legacy-1')).toBe(true) + }) + + it('stays live (and clears the latch) when the owner resolves', async () => { + $connectionsRegistry.set(registry('gw-a', 'gw-b')) + $profiles.set([{ name: 'default' }] as never) + markStoredTranscriptReadOnly('legacy-2') + + const outcome = await resumeWithStoredTranscriptFallback( + 'legacy-2', + async () => { + // Backfilled row: a bare profile is a routable owner in every topology. + assertSessionOwnerResolved('default', { method: 'session.resume', sessionId: 'legacy-2' }) + + return { session_id: 'runtime-2' } + }, + async () => { + throw new Error('stored read must not run on the live path') + } + ) + + expect(outcome.mode).toBe('live') + expect(isStoredTranscriptReadOnly('legacy-2')).toBe(false) + }) + + it('rethrows non-owner-resolution errors without marking read-only', async () => { + const boom = new Error('backend exploded') + + await expect( + resumeWithStoredTranscriptFallback( + 'legacy-3', + async () => { + throw boom + }, + async () => ({ messages: [] }) + ) + ).rejects.toBe(boom) + + expect(isStoredTranscriptReadOnly('legacy-3')).toBe(false) + }) + + it('rethrows the ORIGINAL owner error when even the stored read fails', async () => { + $connectionsRegistry.set(registry('gw-a', 'gw-b')) + $profiles.set([{ name: 'default' }, { name: 'researcher' }] as never) + + await expect( + resumeWithStoredTranscriptFallback( + 'legacy-4', + async () => { + assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'legacy-4' }) + + return {} + }, + async () => { + throw new Error('404 stored row missing') + } + ) + ).rejects.toMatchObject({ name: 'SessionOwnerResolutionError' }) + + expect(isStoredTranscriptReadOnly('legacy-4')).toBe(false) + }) + + it('mints collision-proof synthetic runtime ids and round-trips the latch', () => { + const id = readOnlyRuntimeIdFor('stored-9') + + expect(isReadOnlyRuntimeId(id)).toBe(true) + expect(isReadOnlyRuntimeId('stored-9')).toBe(false) + expect(isReadOnlyRuntimeId(null)).toBe(false) + + markStoredTranscriptReadOnly('stored-9') + expect(isStoredTranscriptReadOnly('stored-9')).toBe(true) + clearStoredTranscriptReadOnly('stored-9') + expect(isStoredTranscriptReadOnly('stored-9')).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/read-only-transcript.ts b/apps/desktop/src/store/read-only-transcript.ts new file mode 100644 index 000000000000..72e93399e427 --- /dev/null +++ b/apps/desktop/src/store/read-only-transcript.ts @@ -0,0 +1,114 @@ +/** + * Read-only stored-transcript resume — the no-owner recovery path (#94724). + * + * The fail-closed owner ladder is correct: a session-scoped RPC whose owner + * nobody can name must NOT ride the ambient socket. But "fail closed" must + * not mean "locked out of your own history": the transcripts of legacy + * unowned rows sit fully intact in state.db, reachable over the id-only REST + * read that needs no live-session routing at all. + * + * `resumeWithStoredTranscriptFallback` wraps a live `session.resume` dispatch + * with exactly that recovery: when — and only when — the resume fails with + * `SessionOwnerResolutionError` (owner genuinely unresolvable under registry + * topology), it opens the stored transcript read-only instead of dead-ending. + * Every other failure keeps its existing semantics. Rows whose owner IS + * resolvable never take this path, and a later successful live resume (e.g. + * after the single-match owner backfill stamps the row) clears the flag. + */ +import { atom } from 'nanostores' + +import type { SessionOwnerResolutionError } from './session-owner-resolution'; +import { isSessionOwnerResolutionError } from './session-owner-resolution' + +/** Stored session ids currently open as read-only stored transcripts. The + * composer/submit surfaces consult this to refuse writes into a session + * that has no routable live runtime. */ +export const $readOnlyStoredTranscripts = atom>(new Set()) + +export function markStoredTranscriptReadOnly(storedSessionId: string): void { + const id = storedSessionId.trim() + + if (!id || $readOnlyStoredTranscripts.get().has(id)) { + return + } + + $readOnlyStoredTranscripts.set(new Set([...$readOnlyStoredTranscripts.get(), id])) +} + +export function clearStoredTranscriptReadOnly(storedSessionId: string): void { + const id = storedSessionId.trim() + + if (!id || !$readOnlyStoredTranscripts.get().has(id)) { + return + } + + const next = new Set($readOnlyStoredTranscripts.get()) + + next.delete(id) + $readOnlyStoredTranscripts.set(next) +} + +export function isStoredTranscriptReadOnly(storedSessionId: null | string | undefined): boolean { + return Boolean(storedSessionId && $readOnlyStoredTranscripts.get().has(storedSessionId.trim())) +} + +/** Synthetic runtime-id namespace for read-only tiles: a stored transcript + * opened without a live runtime still needs a state-cache key, and this + * prefix guarantees it can never collide with (or be mistaken for) a real + * gateway runtime id. */ +export const READ_ONLY_RUNTIME_ID_PREFIX = 'read-only:' + +export function readOnlyRuntimeIdFor(storedSessionId: string): string { + return `${READ_ONLY_RUNTIME_ID_PREFIX}${storedSessionId}` +} + +export function isReadOnlyRuntimeId(runtimeId: null | string | undefined): boolean { + return Boolean(runtimeId?.startsWith(READ_ONLY_RUNTIME_ID_PREFIX)) +} + +export type StoredTranscriptResumeOutcome = + | { mode: 'live'; resumed: TResumed } + | { error: SessionOwnerResolutionError; mode: 'read-only'; transcript: TTranscript } + +/** + * Dispatch a live resume, recovering into a read-only stored-transcript open + * when the owner is unresolvable. `fetchStoredTranscript` must be an id-only + * stored read (REST `/api/sessions/:id/messages`) that performs NO gateway + * routing — that is the whole point of the recovery path. + * + * When even the stored read fails, the ORIGINAL owner-resolution error is + * rethrown: the caller's existing error UX (retry latch, stranded screen) + * stays authoritative and no misleading transport error replaces the real + * diagnosis. + */ +export async function resumeWithStoredTranscriptFallback( + storedSessionId: string, + resume: () => Promise, + fetchStoredTranscript: () => Promise +): Promise> { + try { + const resumed = await resume() + + // A live resume proves the owner is routable again (the backfill stamped + // the row, or a topology change resolved it) — drop the read-only latch. + clearStoredTranscriptReadOnly(storedSessionId) + + return { mode: 'live', resumed } + } catch (error) { + if (!isSessionOwnerResolutionError(error)) { + throw error + } + + let transcript: TTranscript + + try { + transcript = await fetchStoredTranscript() + } catch { + throw error + } + + markStoredTranscriptReadOnly(storedSessionId) + + return { error, mode: 'read-only', transcript } + } +}