diff --git a/tests/test_console.py b/tests/test_console.py index a2ab72f6..336463df 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1105,18 +1105,21 @@ class TestConsoleHTTPEndpoints: def test_index_landing_surfaces(self, client): status, body, ct = self._get_raw(client, "/") assert status == 200 - # Nodes are reached through the bottom-bar node picker; the old - # always-visible NODES table was replaced by it. - assert 'id="csb-node-picker"' in body - assert 'id="csb-np-trigger"' in body - assert 'id="csb-np-menu"' in body + # Node discovery moved to the L-shell RAIL; the legacy bottom-bar node + # picker (and #cluster-status-bar) was retired by the renovation — guard + # against reintroduction (mirrors test_shell_js bottom-bar-retired). + assert 'id="csb-node-picker"' not in body + assert 'id="cluster-status-bar"' not in body + # The landing now boots the shared shell module, which builds the rail + + # tab-bar + pane host and hands off to the legacy boot. + assert "/shared/shell.js" in body # Removed in the 1.5.0 landing-page cleanup — guard against # accidental reintroduction. assert 'id="new-ws-overlay"' not in body assert 'id="new-ws-btn"' not in body assert 'id="cluster-summary-compact"' not in body assert 'id="view-node"' not in body - # Replaced by the node picker — guard against reintroduction. + # Replaced by the rail — guard against reintroduction. assert 'id="view-overview"' not in body assert 'id="node-table"' not in body diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py index f360f174..373b0084 100644 --- a/tests/test_shell_js.py +++ b/tests/test_shell_js.py @@ -182,6 +182,80 @@ def test_console_launcher_routes_by_persona() -> None: assert 'id="launcher-personas"' in index, "the persona toggle must be in the launcher panel" +def test_console_launcher_creates_open_panes() -> None: + """Workstream-lifecycle bugfix: BOTH launcher personas open the new session as + an L-shell PANE (openPane), not a full-page nav — coordinator and interactive + alike. Full-page nav survives only as the shell-absent fallback.""" + app = _CONSOLE_APP.read_text(encoding="utf-8") + assert 'pm.openPane("coordinator", res.data.ws_id)' in app, ( + "coordinator create must open a pane, not full-page nav" + ) + assert 'pm.openPane("interactive", wsId, { nodeId: node || null })' in app, ( + "interactive create must open a node-proxied pane with the created node as hint" + ) + # The saved-row interactive resume opens a pane (the pane resolves+opens the + # node itself) — the bespoke restoreInteractiveSession helper is retired. + assert 'pm.openPane("interactive", s.ws_id, { nodeId: s.node_id || null })' in app, ( + "saved interactive resume must open a pane with the origin node as hint" + ) + assert "function restoreInteractiveSession" not in app, ( + "restoreInteractiveSession is folded into resolveInteractiveNode + the pane factory" + ) + + +def test_console_resolve_interactive_node_seam() -> None: + """The console exposes resolveInteractiveNode(wsId, hint): origin-first + POST /open (reuse a session already loaded on its node, no duplicate), with a + rendezvous (/v1/api/route) fallback when the origin is gone. This is what + makes a node-proxied pane survive a reload — the shell's interactive factory + calls it before streaming (the node /events 404s on a not-loaded ws).""" + app = _CONSOLE_APP.read_text(encoding="utf-8") + assert "window.TS_APP.resolveInteractiveNode = function (wsId, hintNodeId)" in app + assert "/v1/api/workstreams/" in app and '"/open"' in app, ( + "origin-first must POST the node /open verb" + ) + assert '"/v1/api/route?ws_id="' in app, "must rendezvous-fallback when the origin is gone" + + +def test_console_launcher_node_strategy() -> None: + """Workstream-lifecycle bugfix: the interactive launcher gains a node-selection + strategy (Least loaded | Specific node) with a live node picker, and the shared + composer's task hint + node fields track the active persona.""" + app = _CONSOLE_APP.read_text(encoding="utf-8") + assert 'id: "node_strategy"' in app and 'id: "node_id"' in app, ( + "launcher must expose the node-strategy + node-picker option fields" + ) + assert "function _applyLauncherFields" in app, ( + "persona switch must update the hint + node-field visibility" + ) + assert "function _populateLauncherNodes" in app, ( + "the specific-node picker must populate from the live cluster snapshot" + ) + assert 'opts.node_strategy === "node"' in app, ( + "interactive create must pin to the chosen node only under the Specific strategy" + ) + composer = (_SHARED / "composer.js").read_text(encoding="utf-8") + assert "Composer.prototype.setPlaceholder" in composer, ( + "composer must support a per-persona placeholder swap" + ) + assert "Composer.prototype.setOptionFieldVisible" in composer, ( + "composer must support conditionally revealing an option field" + ) + + +def test_pane_persists_meta_for_rehydrate() -> None: + """Workstream-lifecycle bugfix: PaneManager persists a pane's serializable + open-time meta (the interactive pane's resolved nodeId) and hands it back as + `extra` on rehydrate, so a reload restores a node-proxied pane onto the SAME + node (origin-first) instead of re-routing + duplicate-loading.""" + pane = _PANE_JS.read_text(encoding="utf-8") + assert "setPaneMeta(paneId, meta)" in pane, "PaneManager must expose setPaneMeta" + assert "entry.meta = p.meta" in pane, "_persist must include a pane's meta when present" + assert "this.openPane(item.type, item.id, item.meta)" in pane, ( + "rehydrate must hand the persisted meta back to the factory as extra" + ) + + def test_step3_admin_pane_registered_and_manage_mounted() -> None: """Step 3: the shell registers the singleton Admin pane (which adopts #view-admin) and mounts the rail's Manage groups from the admin IA seam.""" @@ -278,22 +352,31 @@ def test_step4_coordinator_pane_registered_and_wired() -> None: def test_step5_interactive_pane_registered_and_wired() -> None: """Step 5b: the shell registers a ws_id-keyed interactive pane over the NODE-PROXIED transport. Both panes are ES modules the shell IMPORTS (step - 5e.0 lifted the coordinator off window too); its node is - derived from the Tier-1 snapshot (nodeForWs) or an open-time hint; the rail - opens it as a pane passing the owning node; and the console loads the shared - interactive stylesheet.""" + 5e.0 lifted the coordinator off window too). The pane is REHYDRATE-SAFE: on + first activate it RESOLVES its owning node and (re)opens the session there + before streaming (ensureInteractiveNode -> the console's resolveInteractiveNode + origin-first POST /open; the node /events stream 404s on a ws not loaded on + that node, so a reloaded pane can't connect blind), then PERSISTS the resolved + node so a reload restores the pane onto the same node. The rail opens it + passing the owning node; the console loads the shared interactive stylesheet.""" shell = _SHELL_JS.read_text(encoding="utf-8") assert 'import { createInteractivePane } from "./interactive.js"' in shell, ( "interactive is ESM — the shell imports it (as it now does the coordinator)" ) assert 'registerType("interactive"' in shell, "shell must register the interactive pane type" assert "createInteractivePane(this.bodyEl, id, {" in shell, ( - "onMount must build the controller into the pane body" + "first activate must build the controller into the pane body" ) - assert "nodeForWs(id)" in shell, ( - "the node-proxy target must be derived from Tier-1 (rehydrate-safe)" + # Rehydrate-safety: the node is RESOLVED + the session (re)opened before the + # pane streams, and the resolved node is persisted for the next reload. + assert "ensureInteractiveNode(" in shell, ( + "the node-proxy target must be resolved (origin-first open) — rehydrate-safe" + ) + assert "function ensureInteractiveNode(" in shell, "shell must define the node resolver/opener" + assert "function nodeForWs(" in shell, "shell must keep the Tier-1 node fallback" + assert "pm.setPaneMeta(pane.id" in shell, ( + "the resolved node must be persisted so a reload restores the same node" ) - assert "function nodeForWs(" in shell, "shell must define the node resolver" # Focus-tracking lifecycle: connect/deactivate/destroy + login re-arm. for hook in ("this._ctl.connect()", "this._ctl.deactivate()", "this._ctl.destroy()"): assert hook in shell, f"interactive pane missing lifecycle {hook!r}" diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 06651ff0..f647db03 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -994,8 +994,15 @@ function _createCoordinator(opts) { return; } onSuccess(res); - window.location.href = - "/coordinator/" + encodeURIComponent(res.data.ws_id); + // Open the new coordinator as an L-shell PANE (the renovation: sessions + // open as tabs, not full-page nav). Full-page nav stays as the + // shell-absent fallback. The session was just created + is live in the + // console, so the coordinator pane connects straight away (no /open hop). + const pm = window.TS_SHELL && window.TS_SHELL.panes; + if (pm) pm.openPane("coordinator", res.data.ws_id); + else + window.location.href = + "/coordinator/" + encodeURIComponent(res.data.ws_id); }) .catch(function () { setBusy(false); @@ -1028,6 +1035,50 @@ function _setLauncherKind(kind, focus) { btn.tabIndex = on ? 0 : -1; // roving tabindex — the radiogroup is one tab stop if (on && focus) btn.focus(); }); + _applyLauncherFields(); +} + +// Reflect the active persona in the shared launcher composer: the task-prompt +// hint, and which option fields are relevant. The node picker is +// interactive-only (coordinators run in the console, not on a compute node); +// its node list appears only under the "Specific node" strategy. +function _applyLauncherFields() { + if (!_homeCoordComposer) return; + const interactive = _launcherKind === "interactive"; + _homeCoordComposer.setPlaceholder( + interactive + ? "What do you want to work on?" + : "What should this coordinator orchestrate?", + ); + _homeCoordComposer.setOptionFieldVisible("node_strategy", interactive); + const specific = + interactive && + _homeCoordComposer.getOptionValue("node_strategy") === "node"; + _homeCoordComposer.setOptionFieldVisible("node_id", specific); + if (specific) _populateLauncherNodes(); +} + +// Populate the launcher's "Specific node" picker from the live Tier-1 snapshot +// (the SAME clusterState the rail node list reads), excluding the `console` +// pseudo-node (interactive sessions run on compute nodes) and unreachable +// nodes. Re-read on each reveal so a node that just (dis)appeared is current. +function _populateLauncherNodes() { + if (!_homeCoordComposer) return; + const choices = []; + if (clusterState && clusterState.nodes) { + Object.keys(clusterState.nodes) + .filter(function (nid) { + return nid !== "console"; + }) + .sort() + .forEach(function (nid) { + const n = clusterState.nodes[nid]; + if (n && n.reachable === false) return; + const count = n && n.workstreams ? n.workstreams.length : 0; + choices.push({ value: nid, text: nid + " (" + count + " ws)" }); + }); + } + _homeCoordComposer.setOptionChoices("node_id", choices); } function _wireLauncherToggle() { @@ -1081,10 +1132,23 @@ function _createInteractive(opts) { const setBusy = opts.setBusy || function () {}; const onSuccess = opts.onSuccess || function () {}; + // Node placement (launcher node-strategy picker): "node" pins to the chosen + // node; anything else lets the console pick the least-loaded node ("auto"). + // Validate the specific-node choice BEFORE flipping busy so a missing pick + // surfaces inline without a stuck spinner. + let placement = "auto"; + if (opts.node_strategy === "node") { + placement = (opts.node_id || "").trim(); + if (!placement) { + errEl.textContent = "Choose a node, or switch to Least loaded."; + return; + } + } + errEl.textContent = ""; setBusy(true); - const body = { node_id: "auto" }; + const body = { node_id: placement }; if (name) body.name = name; if (skill) body.skill = skill; if (model) body.model = model; @@ -1111,7 +1175,17 @@ function _createInteractive(opts) { return; } onSuccess(res); - if (node) { + // Open the new session as an L-shell PANE (interactive sessions are + // node-proxied; the pane resolves + opens on its node before streaming). + // Full-page nav stays as the shell-absent fallback. + const pm = window.TS_SHELL && window.TS_SHELL.panes; + if (pm) { + // target_node is the just-created live node; pass it as the open-time + // hint so the pane pins there (origin-first) instead of re-routing. A + // missing node (server-contract drift) still recovers via the pane's + // rendezvous fallback. + pm.openPane("interactive", wsId, { nodeId: node || null }); + } else if (node) { window.location.href = "/node/" + encodeURIComponent(node) + @@ -1341,8 +1415,21 @@ function _mountHomeCoordComposer() { if (v.skill) bits.push(v.skill); if (v.model) bits.push(v.model); if (v.judge_model) bits.push("judge: " + v.judge_model); + // Node placement is interactive-only; surface it only when a specific + // node is pinned (the "Least loaded" default needs no summary line). + if ( + _launcherKind === "interactive" && + v.node_strategy === "node" && + v.node_id + ) + bits.push("node: " + v.node_id); return bits.join(" \u00b7 "); }, + // Re-evaluate the interactive node-picker visibility whenever a field + // changes (the node list appears only under the "Specific node" strategy). + onChange: function () { + _applyLauncherFields(); + }, fields: [ { id: "name", @@ -1373,6 +1460,27 @@ function _mountHomeCoordComposer() { // session model — see IntentJudge.__init__). choices: [{ value: "", text: "Default model" }], }, + // Node placement — INTERACTIVE persona only (coordinators run in the + // console, not on a compute node). _applyLauncherFields shows/hides + // these per persona. "auto" → the console picks the least-loaded node; + // "node" → reveal the live node picker below + pin to the chosen node. + { + id: "node_strategy", + label: "Node", + type: "select", + choices: [ + { value: "auto", text: "Least loaded" }, + { value: "node", text: "Specific node…" }, + ], + }, + { + id: "node_id", + label: "Pick node", + type: "select", + // First option is the placeholder; _populateLauncherNodes appends the + // live reachable compute nodes (excluding the console pseudo-node). + choices: [{ value: "", text: "Select a node" }], + }, ], }, attachments: { @@ -1547,6 +1655,9 @@ function submitHomeCoord(textFromComposer) { ); return; } + // Node placement from the launcher's node-strategy picker (interactive-only). + shared.node_strategy = opts.node_strategy || "auto"; + shared.node_id = (opts.node_id || "").trim(); _createInteractive(shared); } else { shared.files = files; @@ -1639,93 +1750,6 @@ function loadSavedCoordinators() { }); } -// Restore a saved interactive session from the console onto a compute node. -// Origin-FIRST: rehydrate on the session's origin node (the saved DTO's -// node_id, stamped at create) whenever it is still reachable. This keeps -// node affinity and, crucially, REUSES a session already live on its origin -// instead of loading a duplicate copy elsewhere; the interactive pane talks -// directly to /node/{id} for every verb, so the load-node and the pane-node -// must be the same node (no split-brain). Only when the origin is gone -// (POST /open 404 = node not in registry / 502 = unreachable) do we re-home -// onto a fresh rendezvous node via GET /v1/api/route (the router skips dead -// nodes; persistence is shared ws_id-keyed Postgres, so any live node is -// state-safe). Rehydrating is required, not just node selection: the -// per-pane SSE /events 404s on a not-loaded ws and /history alone will not -// load it. Mirrors the coordinator open-before-navigate and the standalone -// dashboardResumeSession. -function restoreInteractiveSession(wsId, originNodeId, rowEl) { - const pm = window.TS_SHELL && window.TS_SHELL.panes; - if (rowEl) rowEl.classList.add("is-busy"); - const clearBusy = function () { - if (rowEl) rowEl.classList.remove("is-busy"); - }; - const failToast = function (status) { - clearBusy(); - if (status === 429) - showToast("Node at capacity; close a session and retry"); - else if (status === 403) showToast("Not permitted to restore this session"); - else showToast("Failed to restore session (" + status + ")"); - }; - // Rehydrate on a specific node, then pin the pane to that SAME node. - // Resolves true on success, else the failing HTTP status. - const openOn = function (nodeId) { - return authFetch( - "/node/" + - encodeURIComponent(nodeId) + - "/v1/api/workstreams/" + - encodeURIComponent(wsId) + - "/open", - { method: "POST" }, - ).then(function (r) { - if (!r.ok) return r.status; - clearBusy(); - if (pm) pm.openPane("interactive", wsId, { nodeId: nodeId }); - return true; - }); - }; - // Re-home onto a live rendezvous node (router skips the dead origin) when - // there is no origin or the origin node is gone. - const routeFallback = function () { - return authFetch("/v1/api/route?ws_id=" + encodeURIComponent(wsId)) - .then(function (r) { - if (!r.ok) { - clearBusy(); - showToast( - r.status === 503 - ? "No nodes available to restore this session" - : "Could not locate session node (" + r.status + ")", - ); - return null; - } - return r.json(); - }) - .then(function (route) { - if (!route) return; - if (!route.node_id) { - clearBusy(); - showToast("Could not locate session node"); - return; - } - return openOn(route.node_id).then(function (res) { - if (res !== true) failToast(res); - }); - }); - }; - // Origin first; 404/502 (origin gone) -> rendezvous fallback. Capacity - // (429) / permission (403) are surfaced, NOT silently re-homed. - const flow = originNodeId - ? openOn(originNodeId).then(function (res) { - if (res === true) return; - if (res === 404 || res === 502) return routeFallback(); - failToast(res); - }) - : routeFallback(); - flow.catch(function () { - clearBusy(); - showToast("Failed to restore session"); - }); -} - // Saved Coordinators table — same shared createSavedTable as the server UI // (/shared/cards.js), with a CHILDREN column instead of MSGS and the // body-keyed (router-proxied) delete. Activation POSTs /open before @@ -1777,12 +1801,15 @@ const _coordTable = createSavedTable({ const pm = window.TS_SHELL && window.TS_SHELL.panes; // Interactive sessions live on a compute node and, unlike coordinators, // have no warm pool: a dormant one must be routed to a live node and - // rehydrated there before its pane can stream (see - // restoreInteractiveSession). Shell-absent falls back to a best-effort - // full-page nav to the origin node, whose detail page rehydrates lazily. + // rehydrated there before its pane can stream. The interactive pane does + // exactly that on first activate (resolveInteractiveNode: origin-first + // POST /open with a rendezvous fallback), so the saved-row click just opens + // the pane with the origin node as the hint. Shell-absent falls back to a + // best-effort full-page nav to the origin node, whose detail page + // rehydrates lazily. if (s.kind !== "coordinator") { if (pm) { - restoreInteractiveSession(s.ws_id, s.node_id, rowEl); + pm.openPane("interactive", s.ws_id, { nodeId: s.node_id || null }); } else if (s.node_id) { window.location.href = "/node/" + @@ -1925,6 +1952,82 @@ window.TS_APP.focusLauncher = function () { if (_homeCoordComposer && typeof _homeCoordComposer.focus === "function") _homeCoordComposer.focus(); }; +// Resolve the cluster node that should host an interactive pane's session AND +// ensure the session is loaded there before the pane streams — the node /events +// stream 404s on a ws not loaded on that node, and /history alone won't load it. +// The shell's interactive pane factory calls this on first activate (fresh open, +// saved-row resume, AND reload-rehydrate all funnel through here). +// +// Origin-FIRST: POST /open on the hint node (the live / origin / just-created +// node) — this REUSES a session already loaded there instead of loading a +// duplicate copy elsewhere, and keeps node affinity (the pane talks directly to +// /node/{id} for every verb, so load-node == pane-node). Only when the hint +// node is gone (404 = not in the registry / 502 = unreachable) or there is no +// hint do we re-home onto a live rendezvous node via GET /v1/api/route (the +// router skips dead nodes; persistence is shared ws_id-keyed Postgres, so any +// live node is state-safe). Capacity (429) / permission (403) surface as an +// error — NOT a silent re-home. Resolves to {nodeId} on success, else {error}. +window.TS_APP.resolveInteractiveNode = function (wsId, hintNodeId) { + const openOn = function (nodeId) { + return authFetch( + "/node/" + + encodeURIComponent(nodeId) + + "/v1/api/workstreams/" + + encodeURIComponent(wsId) + + "/open", + { method: "POST" }, + ).then(function (r) { + return r.ok ? { nodeId: nodeId } : { status: r.status }; + }); + }; + const failResult = function (status) { + if (status === 429) { + showToast("Node at capacity; close a session and retry"); + return { error: "Node at capacity — close a session and retry." }; + } + if (status === 403) { + showToast("Not permitted to open this session"); + return { error: "You don’t have permission to open this session." }; + } + return { error: "Could not open this session (" + status + ")." }; + }; + const routeFallback = function () { + return authFetch("/v1/api/route?ws_id=" + encodeURIComponent(wsId)) + .then(function (r) { + if (!r.ok) { + const msg = + r.status === 503 + ? "No nodes available to open this session." + : "Could not locate the session node (" + r.status + ")."; + showToast(msg); + return { error: msg }; + } + return r.json(); + }) + .then(function (route) { + if (!route) return { error: "Could not locate the session node." }; + if (route.error) return route; // route fetch already failed + toasted + if (!route.node_id) { + showToast("Could not locate the session node"); + return { error: "Could not locate the session node." }; + } + return openOn(route.node_id).then(function (res) { + return res.nodeId ? res : failResult(res.status); + }); + }); + }; + const flow = hintNodeId + ? openOn(hintNodeId).then(function (res) { + if (res.nodeId) return res; + // Origin gone -> rendezvous re-home; capacity/permission surface as-is. + if (res.status === 404 || res.status === 502) return routeFallback(); + return failResult(res.status); + }) + : routeFallback(); + return flow.catch(function () { + return { error: "Failed to open this session." }; + }); +}; window.TS_APP.boot = function () { history.replaceState({ view: "home" }, ""); initLogin(); diff --git a/turnstone/shared_static/composer.js b/turnstone/shared_static/composer.js index 297f605b..159ee746 100644 --- a/turnstone/shared_static/composer.js +++ b/turnstone/shared_static/composer.js @@ -241,6 +241,15 @@ if (this.modelChipEl) this.modelChipEl.textContent = label || "—"; }; + // Swap the textarea's idle placeholder — e.g. a launcher that reuses one + // composer across personas updates the task-prompt hint when the persona + // changes. Leaves a busy-state placeholder swap (setBusy) untouched. + Composer.prototype.setPlaceholder = function (text) { + this._idlePlaceholder = text == null ? "" : String(text); + if (this.inputEl && !this._busy) + this.inputEl.placeholder = this._idlePlaceholder; + }; + Composer.prototype._buildInput = function (row, opts) { this.inputEl = document.createElement("textarea"); this.inputEl.className = "composer-input"; @@ -742,6 +751,18 @@ this._refreshOptionsSummary(); }; + // Show/hide a single options field (its label + control row). Lets a caller + // reveal a field conditionally — e.g. the launcher shows the node picker only + // for the interactive persona, and the node list only under "Specific node". + // Toggles inline display (robust against the field row's own flex/grid rule, + // which [hidden] alone would lose a specificity battle with). + Composer.prototype.setOptionFieldVisible = function (id, visible) { + var ctrl = this._optionFields && this._optionFields[id]; + if (!ctrl) return; + var row = ctrl.closest(".composer-options-field"); + if (row) row.style.display = visible ? "" : "none"; + }; + Composer.prototype._refreshOptionsSummary = function () { if (!this.optionsSummaryEl) return; var summaryFn = this._opts.options && this._opts.options.summary; diff --git a/turnstone/shared_static/pane.js b/turnstone/shared_static/pane.js index 2014bef8..4b35accb 100644 --- a/turnstone/shared_static/pane.js +++ b/turnstone/shared_static/pane.js @@ -135,6 +135,17 @@ export class PaneManager { pane._glyphEl = el; } + /** Update a pane's persisted open-time meta (a small serializable hint, e.g. + * the interactive pane's resolved `{nodeId}`) and re-persist immediately, so + * the next reload re-opens the pane with the same hint as `extra`. Generic: + * PaneManager treats meta as opaque; the pane type owns its shape. */ + setPaneMeta(paneId, meta) { + const pane = this._panes.get(paneId); + if (!pane) return; + pane.meta = meta; + this._persist(); + } + /** Update a tab's label text in place. The shell drives this from Tier-1 so a * conversational tab tracks its workstream's live NAME instead of freezing at * the open-time id; pane.title is updated too so a later tab rebuild keeps it. */ @@ -157,8 +168,11 @@ export class PaneManager { /** Create the pane if absent, then focus it. Creation is auth-gated by the * type's registered `canOpen` (deny -> no pane); focusing an already-open pane * is never re-gated. `extra` is an optional open-time hint passed straight to - * the factory (e.g. the interactive pane's `{nodeId}` from a rail click); it is - * NOT persisted, so a factory must be able to re-derive it on rehydrate. */ + * the factory (e.g. the interactive pane's `{nodeId}` from a rail click). A + * factory may copy a SERIALIZABLE hint onto `pane.meta` (and refresh it later + * via `setPaneMeta`) to have it persisted and handed back as `extra` on + * rehydrate — the interactive pane does this with its resolved nodeId so a + * reload restores the pane onto the SAME node (no re-route / duplicate load). */ openPane(type, id, extra) { if (!this._types.has(type)) { console.warn("PaneManager: unknown pane type", type); @@ -510,7 +524,12 @@ export class PaneManager { try { const order = this._order.map((paneId) => { const p = this._panes.get(paneId); - return { type: p.type, id: p.rawId }; + const entry = { type: p.type, id: p.rawId }; + // A pane's serializable open-time hint (e.g. interactive nodeId) rides + // along so rehydrate hands it back as `extra` — only when present, so + // hint-less panes (dashboard/admin/coordinator) persist unchanged. + if (p.meta != null) entry.meta = p.meta; + return entry; }); sessionStorage.setItem( this.storageKey, @@ -539,8 +558,9 @@ export class PaneManager { if (item && this._types.has(item.type)) { // Only count it restored if the pane was actually created — an auth-gated // type (a coordinator pane without scope) returns null, and must not - // suppress the Dashboard fallback into a blank shell. - if (this.openPane(item.type, item.id)) restored = true; + // suppress the Dashboard fallback into a blank shell. `item.meta` (e.g. + // the interactive pane's resolved nodeId) is handed back as `extra`. + if (this.openPane(item.type, item.id, item.meta)) restored = true; } } if (state.active && this._panes.has(state.active)) { diff --git a/turnstone/shared_static/shell.css b/turnstone/shared_static/shell.css index 35f1cf4c..774f6231 100644 --- a/turnstone/shared_static/shell.css +++ b/turnstone/shared_static/shell.css @@ -713,6 +713,15 @@ min-height: 0; } +/* Transient status line a conversational pane shows before its controller is + built (e.g. an interactive pane resolving + opening its node-proxied session + on first activate). The error variant rides chat.css's .msg.error styling. */ +.pane-status { + padding: 16px; + color: var(--fg-dim); + font-size: 13px; +} + /* ===== Dashboard session launcher — persona toggle (coordinator | interactive). A console-dashboard control; lives here because the L-shell loads shell.css. */ .launcher-personas { diff --git a/turnstone/shared_static/shell.js b/turnstone/shared_static/shell.js index 6acf90b1..c816a5b8 100644 --- a/turnstone/shared_static/shell.js +++ b/turnstone/shared_static/shell.js @@ -151,6 +151,28 @@ function stateForWs(wsId) { return f ? f.ws.state || "idle" : "idle"; } +// Resolve which node an interactive pane's session lives on, ENSURING the +// session is loaded there before the pane streams. This is the one seam that +// makes a node-proxied session survive a reload: the node /events stream 404s +// on a ws that isn't loaded on that node, so a freshly-rehydrated pane must +// (re)open the session on a node first. Resolves to {nodeId} or {error}. +// - Standalone (no cluster): every session is LOCAL → base "" (nodeId null), +// no open round-trip. +// - Console: the route + proxy + open work is the console's — delegate to the +// TS_APP seam (origin-first POST /open with a rendezvous fallback). Without +// the seam (unexpected on a cluster console) fall back to the open-time hint +// or the live Tier-1 snapshot, accepting the pre-reload behaviour. +function ensureInteractiveNode(caps, wsId, hint) { + if (!caps.cluster) return Promise.resolve({ nodeId: null }); + if ( + window.TS_APP && + typeof window.TS_APP.resolveInteractiveNode === "function" + ) { + return window.TS_APP.resolveInteractiveNode(wsId, hint || null); + } + return Promise.resolve({ nodeId: hint || nodeForWs(wsId) || null }); +} + // Repaint conversational tabs from Tier-1 in ONE pass: a single findWs per // stateful tab feeds BOTH the live state glyph (one Tier-1 writer; the pane's // Tier-2 stream drives its body, not the tab) and the live workstream name. @@ -364,32 +386,61 @@ async function mountShell() { ? () => window.closeWorkstream(id) : null, }); + // Persist the open-time node hint so a reload re-opens on the SAME node + // (origin-first; avoids a re-route + duplicate load). Updated to the + // resolved node after ensureInteractiveNode settles, below. + if (extra && extra.nodeId) pane.meta = { nodeId: extra.nodeId }; pane.onMount = function () { - // Node-proxy transport only exists in a cluster deployment (the console). - // On a single-node standalone (caps.cluster=false) every session is LOCAL, - // so nodeId stays null → the pane uses base="" (no /node/ hop), even - // though the synthesized one-node clusterState would otherwise name a node. - const nodeId = caps.cluster - ? (extra && extra.nodeId) || nodeForWs(id) - : null; - this._ctl = createInteractivePane(this.bodyEl, id, { - nodeId, - onClose: () => pm.close(pane.id), - }); + // The controller is built LAZILY on first activate — only after the owning + // node is resolved AND the session is (re)opened there (the node /events + // stream 404s on a ws not loaded on its node, so a rehydrated pane can't + // just connect blind). onMount only reserves a status line so a + // not-yet-resolved pane isn't a blank box. + this._statusEl = make("div", "pane-status", "Connecting…"); + this.bodyEl.append(this._statusEl); }; pane.onActivate = function () { pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph - if (!this._ctl) return; - this._ctl.connect(); // idempotent — opens the stream once, re-marks focus - if (!this._loginArmed && window.TS_LOGIN && this._ctl.onLogin) { - this._loginArmed = true; - window.TS_LOGIN.subscribe(this._ctl.onLogin); + if (this._ctl) { + this._ctl.connect(); // built — idempotent re-mark focus + return; } + if (this._resolving) return; // first-activate resolve already in flight + this._resolving = true; + const hint = (this.meta && this.meta.nodeId) || (extra && extra.nodeId); + ensureInteractiveNode(caps, id, hint).then((res) => { + this._resolving = false; + if (this._closed) return; // pane closed mid-resolve — don't build into a detached body + if (!res || res.error) { + if (this._statusEl) { + this._statusEl.className = "pane-status msg error"; + this._statusEl.textContent = + (res && res.error) || "Could not connect to this session."; + } + return; + } + // Pin + persist the resolved node so the next reload reuses it. + this.meta = { nodeId: res.nodeId }; + pm.setPaneMeta(pane.id, this.meta); + if (this._statusEl && this._statusEl.parentNode) + this._statusEl.remove(); + this._statusEl = null; + this._ctl = createInteractivePane(this.bodyEl, id, { + nodeId: res.nodeId, + onClose: () => pm.close(pane.id), + }); + this._ctl.connect(); + if (window.TS_LOGIN && this._ctl.onLogin) { + this._loginArmed = true; + window.TS_LOGIN.subscribe(this._ctl.onLogin); + } + }); }; pane.onDeactivate = function () { if (this._ctl && this._ctl.deactivate) this._ctl.deactivate(); }; pane.onClose = function () { + this._closed = true; // a pending resolve must not build after close if (this._ctl) { if (window.TS_LOGIN && this._ctl.onLogin) window.TS_LOGIN.unsubscribe(this._ctl.onLogin);