mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(ui): repair interactive/proxied workstream lifecycle (reload, create, launcher)
Workstream-lifecycle bugfixes on the L-shell: - Node-proxied interactive panes now SURVIVE a browser reload. On first activate a pane resolves its owning node and (re)opens the session there before streaming — the node /events stream 404s on a ws not loaded on its node, so a rehydrated pane could not just connect blind. Resolution is origin-first via the new TS_APP.resolveInteractiveNode seam (POST /open with a rendezvous /route fallback). PaneManager now persists a pane's resolved nodeId as opaque meta and hands it back on rehydrate, so a reload restores the pane onto the SAME node even before the Tier-1 snapshot has populated — the exact timing that used to strand it on base="" (the console, not a node). - Both launcher personas open the new session as a PANE, not a full-page nav (coordinator -> coordinator pane; interactive -> node-proxied pane); the full-page nav stays only as the shell-absent fallback. Every interactive entry point (create, active row, rail, saved row, child link, reload) now funnels through one resolve-open-connect path, folding away the bespoke restoreInteractiveSession helper. - The interactive launcher gains a node-selection strategy (Least loaded | Specific node, with a live node picker fed from the cluster snapshot) and a persona-aware task hint — the shared composer no longer shows "...coordinator orchestrate?" when the interactive persona is selected. Guards updated to pin the new wiring; the stale console landing test (asserting the renovation-retired bottom-bar node picker) is corrected to the rail.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+91
-8
@@ -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}"
|
||||
|
||||
+198
-95
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<id> 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);
|
||||
|
||||
Reference in New Issue
Block a user