fix: persist active_remote_id in state for remote session restore
Remote sessions failed to reconnect after page refresh because restoreState() and renderSheetList() didn't pass remoteId to openSession(). Fix: - state.py: add active_remote_id field to empty_state() - main.py: extend StatePatch model to accept active_session and active_remote_id; update PATCH /api/state to selectively update only fields explicitly provided (using model_fields_set) - app.js restoreState(): read state.active_remote_id and pass it as remoteId option so page-refresh reconnects remote sessions - app.js openSession(): fire-and-forget PATCH /api/state to persist active_session + active_remote_id after successful connect - app.js closeSession(): fire-and-forget PATCH /api/state to clear both fields so refresh doesn't try to reopen a closed session - app.js renderSheetList(): add data-remote-id to sheet items and pass remoteId in click handler (previously called openSession(name) with no remoteId, routing remote sessions to local 404 endpoint) Fixes: POST /api/sessions/paperclip-adapter/connect 404 when clicking a remote session from the mobile bottom sheet or after page refresh.
This commit is contained in:
+13
-3
@@ -219,7 +219,10 @@ async function restoreState() {
|
|||||||
const res = await api('GET', '/api/state');
|
const res = await api('GET', '/api/state');
|
||||||
const state = await res.json();
|
const state = await res.json();
|
||||||
if (state.active_session) {
|
if (state.active_session) {
|
||||||
await openSession(state.active_session, { skipAnimation: true });
|
await openSession(state.active_session, {
|
||||||
|
skipAnimation: true,
|
||||||
|
remoteId: state.active_remote_id || '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[restoreState] could not restore previous session:', err);
|
console.warn('[restoreState] could not restore previous session:', err);
|
||||||
@@ -1241,6 +1244,9 @@ async function openSession(name, opts = {}) {
|
|||||||
return closeSession();
|
return closeSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist active_remote_id so restoreState() can reopen remote sessions after page refresh
|
||||||
|
api('PATCH', '/api/state', { active_session: name, active_remote_id: _remoteId || null }).catch(function() {});
|
||||||
|
|
||||||
// Fire-and-forget bell-clear for remote sessions — acknowledge bells on the remote server
|
// Fire-and-forget bell-clear for remote sessions — acknowledge bells on the remote server
|
||||||
if (_remoteId !== '') {
|
if (_remoteId !== '') {
|
||||||
api('POST', '/api/federation/' + encodeURIComponent(_remoteId) + '/sessions/' + encodeURIComponent(name) + '/bell/clear').catch(function() {});
|
api('POST', '/api/federation/' + encodeURIComponent(_remoteId) + '/sessions/' + encodeURIComponent(name) + '/bell/clear').catch(function() {});
|
||||||
@@ -1267,6 +1273,8 @@ function closeSession() {
|
|||||||
if (_viewingRemoteId === '') {
|
if (_viewingRemoteId === '') {
|
||||||
api('DELETE', '/api/sessions/current').catch(function() {});
|
api('DELETE', '/api/sessions/current').catch(function() {});
|
||||||
}
|
}
|
||||||
|
// Clear active_remote_id so a page refresh does not attempt to reopen the remote session
|
||||||
|
api('PATCH', '/api/state', { active_session: null, active_remote_id: null }).catch(function() {});
|
||||||
_viewingRemoteId = '';
|
_viewingRemoteId = '';
|
||||||
|
|
||||||
const expanded = $('view-expanded');
|
const expanded = $('view-expanded');
|
||||||
@@ -1857,8 +1865,9 @@ function renderSheetList() {
|
|||||||
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||||
var isActive = s.name === _viewingSession;
|
var isActive = s.name === _viewingSession;
|
||||||
var escapedName = escapeHtml(s.name || '');
|
var escapedName = escapeHtml(s.name || '');
|
||||||
|
var remoteIdAttr = s.remoteId ? ' data-remote-id="' + escapeHtml(s.remoteId) + '"' : '';
|
||||||
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
||||||
' data-session="' + escapedName + '" role="option">' +
|
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
|
||||||
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
||||||
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
|
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
|
||||||
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
||||||
@@ -1869,7 +1878,8 @@ function renderSheetList() {
|
|||||||
item.addEventListener('click', function() {
|
item.addEventListener('click', function() {
|
||||||
closeBottomSheet();
|
closeBottomSheet();
|
||||||
var name = item.dataset.session;
|
var name = item.dataset.session;
|
||||||
if (name !== _viewingSession) openSession(name);
|
var remoteId = item.dataset.remoteId || '';
|
||||||
|
if (name !== _viewingSession) openSession(name, { remoteId: remoteId });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4381,3 +4381,115 @@ test('showNewSessionInput device select has keydown handler for Escape', () => {
|
|||||||
const selectKeydownIdx = fnBody.indexOf("select.addEventListener('keydown'");
|
const selectKeydownIdx = fnBody.indexOf("select.addEventListener('keydown'");
|
||||||
assert.ok(selectKeydownIdx !== -1, 'select element must have a keydown handler in showNewSessionInput');
|
assert.ok(selectKeydownIdx !== -1, 'select element must have a keydown handler in showNewSessionInput');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Fix: remote sessions fail to connect because openSession call sites don't pass remoteId ---
|
||||||
|
|
||||||
|
test('restoreState reads active_remote_id from state and passes it to openSession', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const restoreIdx = source.indexOf('async function restoreState');
|
||||||
|
assert.ok(restoreIdx >= 0, 'restoreState must exist');
|
||||||
|
const restoreEnd = source.indexOf('\n}', restoreIdx) + 2;
|
||||||
|
const restoreBody = source.slice(restoreIdx, restoreEnd);
|
||||||
|
assert.ok(
|
||||||
|
restoreBody.includes('active_remote_id'),
|
||||||
|
'restoreState must read active_remote_id from persisted state',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
restoreBody.includes('remoteId'),
|
||||||
|
'restoreState must pass remoteId to openSession so remote sessions restore correctly',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSheetList click handler passes remoteId to openSession', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||||
|
const sheetIdx = source.indexOf('function renderSheetList');
|
||||||
|
assert.ok(sheetIdx >= 0, 'renderSheetList must exist');
|
||||||
|
// Extract the full function body (up to the closing brace)
|
||||||
|
const sheetEnd = source.indexOf('\n}', sheetIdx) + 2;
|
||||||
|
const sheetBody = source.slice(sheetIdx, sheetEnd);
|
||||||
|
assert.ok(
|
||||||
|
sheetBody.includes('remoteId'),
|
||||||
|
'renderSheetList click handler must pass remoteId to openSession',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSheetList item HTML includes data-remote-id attribute for remote sessions', () => {
|
||||||
|
let capturedHTML = '';
|
||||||
|
const mockList = {
|
||||||
|
get innerHTML() { return capturedHTML; },
|
||||||
|
set innerHTML(v) { capturedHTML = v; },
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
};
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
globalThis.document.getElementById = (id) => (id === 'sheet-list' ? mockList : null);
|
||||||
|
app._setCurrentSessions([{ name: 'remote-sess', remoteId: 'fed-abc123', bell: null }]);
|
||||||
|
|
||||||
|
app.renderSheetList();
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
capturedHTML.includes('data-remote-id="fed-abc123"'),
|
||||||
|
'sheet item should have data-remote-id attribute for remote sessions',
|
||||||
|
);
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openSession PATCHes /api/state with active_remote_id after successful connect', async () => {
|
||||||
|
const fetchCalls = [];
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQS = globalThis.document.querySelector;
|
||||||
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||||
|
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||||
|
globalThis.document.querySelector = () => null;
|
||||||
|
globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves
|
||||||
|
globalThis.window._openTerminal = () => {};
|
||||||
|
|
||||||
|
await app.openSession('remote-session', { remoteId: 'fed-abc123' });
|
||||||
|
|
||||||
|
const patchCall = fetchCalls.find((c) => c.url === '/api/state' && c.opts && c.opts.method === 'PATCH');
|
||||||
|
assert.ok(patchCall, 'openSession should PATCH /api/state after successful connect');
|
||||||
|
const body = JSON.parse(patchCall.opts.body);
|
||||||
|
assert.strictEqual(body.active_remote_id, 'fed-abc123', 'PATCH body should include active_remote_id');
|
||||||
|
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelector = origQS;
|
||||||
|
globalThis.setTimeout = origSetTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closeSession PATCHes /api/state to clear active_remote_id', async () => {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
const origGetById = globalThis.document.getElementById;
|
||||||
|
const origQS = globalThis.document.querySelector;
|
||||||
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
|
|
||||||
|
// Open a remote session first so _viewingRemoteId is set
|
||||||
|
globalThis.fetch = async () => ({ ok: true });
|
||||||
|
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||||
|
globalThis.document.querySelector = () => null;
|
||||||
|
globalThis.setTimeout = (fn) => { fn(); };
|
||||||
|
globalThis.window._openTerminal = () => {};
|
||||||
|
globalThis.window._closeTerminal = () => {};
|
||||||
|
|
||||||
|
await app.openSession('remote-sess', { remoteId: 'fed-abc123' });
|
||||||
|
|
||||||
|
globalThis.setTimeout = origSetTimeout;
|
||||||
|
|
||||||
|
// Reset fetch tracking
|
||||||
|
const fetchCalls = [];
|
||||||
|
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||||
|
|
||||||
|
await app.closeSession();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const patchCall = fetchCalls.find((c) => c.url === '/api/state' && c.opts && c.opts.method === 'PATCH');
|
||||||
|
assert.ok(patchCall, 'closeSession should PATCH /api/state to clear remote session state');
|
||||||
|
const body = JSON.parse(patchCall.opts.body);
|
||||||
|
assert.strictEqual(body.active_remote_id, null, 'PATCH should clear active_remote_id to null');
|
||||||
|
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
globalThis.document.getElementById = origGetById;
|
||||||
|
globalThis.document.querySelector = origQS;
|
||||||
|
globalThis.setTimeout = origSetTimeout;
|
||||||
|
});
|
||||||
|
|||||||
+15
-2
@@ -279,7 +279,9 @@ app.add_middleware(
|
|||||||
|
|
||||||
|
|
||||||
class StatePatch(BaseModel):
|
class StatePatch(BaseModel):
|
||||||
session_order: list[str]
|
session_order: list[str] | None = None
|
||||||
|
active_session: str | None = None
|
||||||
|
active_remote_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatPayload(BaseModel):
|
class HeartbeatPayload(BaseModel):
|
||||||
@@ -332,10 +334,21 @@ async def get_state() -> dict:
|
|||||||
|
|
||||||
@app.patch("/api/state")
|
@app.patch("/api/state")
|
||||||
async def patch_state(patch: StatePatch) -> dict:
|
async def patch_state(patch: StatePatch) -> dict:
|
||||||
"""Update session_order in the persistent state and return the updated state."""
|
"""Update fields in the persistent state and return the updated state.
|
||||||
|
|
||||||
|
Only fields explicitly included in the request body are updated;
|
||||||
|
omitted fields are left unchanged. Supports: session_order,
|
||||||
|
active_session, active_remote_id.
|
||||||
|
"""
|
||||||
async with state_lock:
|
async with state_lock:
|
||||||
state = load_state()
|
state = load_state()
|
||||||
|
changed = patch.model_fields_set
|
||||||
|
if "session_order" in changed:
|
||||||
state["session_order"] = patch.session_order
|
state["session_order"] = patch.session_order
|
||||||
|
if "active_session" in changed:
|
||||||
|
state["active_session"] = patch.active_session
|
||||||
|
if "active_remote_id" in changed:
|
||||||
|
state["active_remote_id"] = patch.active_remote_id
|
||||||
save_state(state)
|
save_state(state)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def empty_state() -> dict:
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"active_session": None,
|
"active_session": None,
|
||||||
|
"active_remote_id": None,
|
||||||
"session_order": [],
|
"session_order": [],
|
||||||
"sessions": {},
|
"sessions": {},
|
||||||
"devices": {},
|
"devices": {},
|
||||||
|
|||||||
@@ -139,6 +139,66 @@ def test_patch_state_rejects_non_list_session_order(client):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_state_updates_active_remote_id(client):
|
||||||
|
"""PATCH /api/state with active_remote_id persists it in state."""
|
||||||
|
response = client.patch(
|
||||||
|
"/api/state",
|
||||||
|
json={"active_session": "remote-sess", "active_remote_id": "fed-abc123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["active_remote_id"] == "fed-abc123"
|
||||||
|
assert data["active_session"] == "remote-sess"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_state_clears_active_remote_id(client):
|
||||||
|
"""PATCH /api/state with active_remote_id: null clears it in state."""
|
||||||
|
from muxplex.state import save_state
|
||||||
|
|
||||||
|
# Set up initial state with active_remote_id
|
||||||
|
initial = {
|
||||||
|
"active_session": "remote-sess",
|
||||||
|
"active_remote_id": "fed-abc123",
|
||||||
|
"session_order": [],
|
||||||
|
"sessions": {},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
save_state(initial)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
"/api/state",
|
||||||
|
json={"active_session": None, "active_remote_id": None},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["active_remote_id"] is None
|
||||||
|
assert data["active_session"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_state_without_session_order_updates_active_remote_id_only(client):
|
||||||
|
"""PATCH /api/state without session_order only updates active_remote_id."""
|
||||||
|
from muxplex.state import save_state
|
||||||
|
|
||||||
|
initial = {
|
||||||
|
"active_session": None,
|
||||||
|
"active_remote_id": None,
|
||||||
|
"session_order": ["alpha", "beta"],
|
||||||
|
"sessions": {},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
save_state(initial)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
"/api/state",
|
||||||
|
json={"active_remote_id": "fed-xyz"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["active_remote_id"] == "fed-xyz"
|
||||||
|
# session_order should be unchanged
|
||||||
|
assert data["session_order"] == ["alpha", "beta"]
|
||||||
|
|
||||||
|
|
||||||
def test_patch_state_ignores_unknown_fields(client):
|
def test_patch_state_ignores_unknown_fields(client):
|
||||||
"""PATCH /api/state ignores unknown fields in the request body."""
|
"""PATCH /api/state ignores unknown fields in the request body."""
|
||||||
response = client.patch(
|
response = client.patch(
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ def test_empty_state_devices_is_empty_dict():
|
|||||||
assert state["devices"] == {}
|
assert state["devices"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_has_active_remote_id_key():
|
||||||
|
state = empty_state()
|
||||||
|
assert "active_remote_id" in state
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_active_remote_id_is_none():
|
||||||
|
state = empty_state()
|
||||||
|
assert state["active_remote_id"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_empty_state_returns_independent_dicts():
|
def test_empty_state_returns_independent_dicts():
|
||||||
"""Mutating one state must not affect another."""
|
"""Mutating one state must not affect another."""
|
||||||
s1 = empty_state()
|
s1 = empty_state()
|
||||||
|
|||||||
Reference in New Issue
Block a user