mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(ui): apply round-2 review + fix-sanity findings (composer caches)
An unprimed convergence re-review found a real login-recovery seam gap plus cleanups (round 1's fix round manufactured one of them); fix-sanity vetted the plan. - console onLoginSuccess recovery seam [0]+[2]: it re-warmed only skills+models after an in-place login; projects+personas (same pre-auth-401 gap) stayed empty (rail group-by-project flat, saved-coordinator raw slugs). Now force-refreshes ALL FOUR caches on login — force so a still-in-flight failing pre-auth fetch yields a trailing AUTHENTICATED refetch rather than coalescing onto the 401 (skills/personas have no *_changed event to recover). Threads an optional callOpts through the four cache modules + console wrappers (backward-compatible; every non-console caller passes nothing). - fork skill paint [4]: the round-1 wrapper extraction left the modal skill paint unconditional on a fork (wasted GET /v1/api/skills + hidden-select rebuild); fork-gate it like model/persona/project. - persona wrapper [5]: extract _paintPersonaSelect so all four composer pickers share the sync-then-refresh wrapper instead of persona being inline-duplicated. - dead machinery [6]: remove the zero-subscriber onModelsChange/onSkillsChange and the models fpExtra fingerprint fold (and the now-orphaned core fpExtra branch). The console repaints models via its direct models_changed handler, not a subscription; the fold only fed the subscriber-only fingerprint. - O(1) modelLabel [7]: index the models cache by alias (keyField) so modelLabel is a getByKey, not a per-paint scan. Declines documented in-code: forks-inherit-model [1] (deliberate) and the fail-open cache [3] (intended, same policy as projects/personas). Deferral comment at the ui onLoginSuccess twin (recovers on dashboard re-focus; follow-up). Tests: rewrote the 7 guards the code changes moved (persona relocation, callOpts threading, force, fpExtra removal) preserving their ordering intent, and added fork-skill-gate, persona-wrapper, all-four-force, keyField, and onModelsChange-removed coverage. 106 pass; ruff + mypy green.
This commit is contained in:
+56
-28
@@ -2412,12 +2412,10 @@ def test_new_ws_modal_paints_project_and_persona_from_cache_synchronously() -> N
|
||||
assert "_paintProjectPicker(projSelect" in fn, (
|
||||
"the modal must paint the project picker via the shared _paintProjectPicker helper"
|
||||
)
|
||||
sync_persona = fn.find("_populatePersonaSelect(personaSelect")
|
||||
async_persona = fn.find("refreshPersonas().then")
|
||||
assert sync_persona >= 0, "the modal must paint the persona picker from cache synchronously"
|
||||
assert async_persona >= 0, "the modal must keep refreshPersonas().then"
|
||||
assert sync_persona < async_persona, (
|
||||
"the synchronous persona paint must precede the async refreshPersonas().then repaint"
|
||||
# Persona is painted via the shared _paintPersonaSelect wrapper (fork-gated);
|
||||
# its sync-before-async ordering is pinned in the wrapper-internals test.
|
||||
assert "_paintPersonaSelect(personaSelect" in fn, (
|
||||
"the modal must paint the persona picker via the shared _paintPersonaSelect helper"
|
||||
)
|
||||
|
||||
|
||||
@@ -2433,12 +2431,9 @@ def test_dashboard_paints_project_and_persona_from_cache_synchronously() -> None
|
||||
assert "_paintProjectPicker(projSel" in fn, (
|
||||
"the dashboard must paint the project picker via the shared _paintProjectPicker helper"
|
||||
)
|
||||
sync_persona = fn.find("_populatePersonaSelect(personaSel")
|
||||
async_persona = fn.find("refreshPersonas().then")
|
||||
assert sync_persona >= 0, "the dashboard must paint the persona picker from cache synchronously"
|
||||
assert async_persona >= 0, "the dashboard must keep refreshPersonas().then"
|
||||
assert sync_persona < async_persona, (
|
||||
"the synchronous dashboard persona paint must precede the async refresh repaint"
|
||||
# Persona is painted via the shared _paintPersonaSelect wrapper (freshOnOpen:false).
|
||||
assert "_paintPersonaSelect(personaSel, { freshOnOpen: false })" in fn, (
|
||||
"the dashboard must paint the persona picker via _paintPersonaSelect (preserving)"
|
||||
)
|
||||
# require_project label-hint parity: the dashboard Project label gained a
|
||||
# .label-hint span, seeded synchronously from requireProject() like the modal.
|
||||
@@ -2460,17 +2455,17 @@ def test_console_launcher_paints_project_and_persona_from_cache_synchronously()
|
||||
# The bare _populateHomeProjectDropdown() call is the sync paint; the refresh's
|
||||
# .then argument has no parens, so this matches only the standalone sync call.
|
||||
sync_proj = proj_fn.find("_populateHomeProjectDropdown()")
|
||||
async_proj = proj_fn.find("refreshProjects().then")
|
||||
async_proj = proj_fn.find("refreshProjects(callOpts).then")
|
||||
assert sync_proj >= 0, "the launcher must paint the project picker from cache synchronously"
|
||||
assert async_proj >= 0, "the launcher must keep refreshProjects().then"
|
||||
assert async_proj >= 0, "the launcher must keep refreshProjects(callOpts).then"
|
||||
assert sync_proj < async_proj, (
|
||||
"the synchronous launcher project paint must precede the async refresh repaint"
|
||||
)
|
||||
persona_fn = _slice_top_level_fn(body, "function _refreshAndPopulatePersonas(")
|
||||
sync_persona = persona_fn.find("_populateHomePersonaDropdown()")
|
||||
async_persona = persona_fn.find("refreshPersonas().then")
|
||||
async_persona = persona_fn.find("refreshPersonas(callOpts).then")
|
||||
assert sync_persona >= 0, "the launcher must paint the persona picker from cache synchronously"
|
||||
assert async_persona >= 0, "the launcher must keep refreshPersonas().then"
|
||||
assert async_persona >= 0, "the launcher must keep refreshPersonas(callOpts).then"
|
||||
assert sync_persona < async_persona, (
|
||||
"the synchronous launcher persona paint must precede the async refresh repaint"
|
||||
)
|
||||
@@ -2532,6 +2527,11 @@ def test_paint_model_and_skill_wrappers_sync_before_refresh() -> None:
|
||||
for wrapper, populate, refresh in (
|
||||
("function _paintModelSelects(", "_populateModelSelect(", "refreshModels().then"),
|
||||
("function _paintSkillSelect(", "_populateSkillSelect(", "refreshSkills().then"),
|
||||
(
|
||||
"function _paintPersonaSelect(",
|
||||
"_populatePersonaSelect(",
|
||||
"refreshPersonas().then",
|
||||
),
|
||||
):
|
||||
fn = _slice_top_level_fn(body, wrapper)
|
||||
sync = fn.find(populate)
|
||||
@@ -2557,8 +2557,8 @@ def test_new_ws_modal_renders_all_selects_fresh_on_open() -> None:
|
||||
assert "_paintSkillSelect(tplSelect, { freshOnOpen: true })" in fn, (
|
||||
"the modal must paint skill fresh-on-open"
|
||||
)
|
||||
assert "_populatePersonaSelect(personaSelect, { fresh: true })" in fn, (
|
||||
"the modal must paint persona fresh-on-open (kind default, no stale carryover)"
|
||||
assert "_paintPersonaSelect(personaSelect, { freshOnOpen: true })" in fn, (
|
||||
"the modal must paint persona fresh-on-open via the shared wrapper"
|
||||
)
|
||||
proj = fn[fn.find("_paintProjectPicker(projSelect") :]
|
||||
assert "freshOnOpen: true" in proj[:120], (
|
||||
@@ -2601,6 +2601,17 @@ def test_new_ws_modal_fork_inherits_model_and_judge() -> None:
|
||||
assert "judgeSelect.hidden = !!_forkFromWsId" in modal, (
|
||||
"modal must hide the judge select for a fork"
|
||||
)
|
||||
# [4] fix: the skill paint is fork-gated too (skill is hidden for a fork).
|
||||
# Tie the gate to the skill paint SPECIFICALLY — a first-gate check would be
|
||||
# satisfied by the model gate at line ~352 even if the skill paint were left
|
||||
# unconditional, so require the nearest preceding gate to be right above it.
|
||||
skill_paint = modal.find("_paintSkillSelect(tplSelect")
|
||||
assert skill_paint >= 0, "the modal must paint the skill picker"
|
||||
gate = modal.rfind("if (!_forkFromWsId) {", 0, skill_paint)
|
||||
assert gate >= 0 and (skill_paint - gate) < 50, (
|
||||
"the modal skill paint must be directly wrapped in `if (!_forkFromWsId)` "
|
||||
"(skip the wasted fetch + hidden-select rebuild on a fork)"
|
||||
)
|
||||
submit = _slice_top_level_fn(body, "function submitNewWs(")
|
||||
assert "if (model && !_forkFromWsId) body.model = model;" in submit, (
|
||||
"submitNewWs must fork-gate body.model (distinct from the judge line)"
|
||||
@@ -2624,21 +2635,26 @@ def test_console_launcher_paints_model_and_skill_from_cache_synchronously() -> N
|
||||
)
|
||||
skill_fn = _slice_top_level_fn(body, "function _refreshAndPopulateSkills(")
|
||||
sync_skill = skill_fn.find("_populateHomeSkillDropdown()")
|
||||
async_skill = skill_fn.find("refreshSkills().then")
|
||||
async_skill = skill_fn.find("refreshSkills(callOpts).then")
|
||||
assert 0 <= sync_skill < async_skill, (
|
||||
"the synchronous launcher skill paint must precede the async refresh repaint"
|
||||
)
|
||||
|
||||
|
||||
def test_console_relogin_rewarms_model_and_skill() -> None:
|
||||
"""onLoginSuccess re-warms BOTH the skill and model caches after auth lands —
|
||||
the boot-time pass runs pre-login (401), so without the model re-warm the
|
||||
console model dropdown would stay at its placeholder until a reload."""
|
||||
def test_console_relogin_rewarms_all_four_caches_with_force() -> None:
|
||||
"""onLoginSuccess re-warms ALL FOUR composer caches after auth lands (the boot
|
||||
pass runs pre-login -> 401 -> fail-open empty), EACH with {force:true} so a
|
||||
still-in-flight failing pre-auth fetch yields a trailing authenticated refetch
|
||||
(skills/personas have no *_changed event to recover otherwise). Fixes [0]+[2]."""
|
||||
body = _CONSOLE_APP_JS.read_text(encoding="utf-8")
|
||||
start = body.index("window.onLoginSuccess = function ()")
|
||||
# The four re-warm calls live before the // Active-coordinators marker; an
|
||||
# assert falling outside this slice fails loudly rather than silently passing.
|
||||
login = body[start : body.index("// Active-coordinators", start)]
|
||||
assert "_refreshAndPopulateSkills()" in login, "onLoginSuccess must re-warm skills"
|
||||
assert "_refreshAndPopulateModels()" in login, "onLoginSuccess must re-warm models"
|
||||
for name in ("Skills", "Models", "Projects", "Personas"):
|
||||
assert f"_refreshAndPopulate{name}({{ force: true }})" in login, (
|
||||
f"onLoginSuccess must force-re-warm {name.lower()} after login"
|
||||
)
|
||||
|
||||
|
||||
def test_models_changed_forces_trailing_single_repaint_path() -> None:
|
||||
@@ -2668,6 +2684,13 @@ def test_models_label_centralized_on_bridge() -> None:
|
||||
"models.js must register modelLabel on the window.TurnstoneModels bridge "
|
||||
"(classic bundles call it via the bridge)"
|
||||
)
|
||||
# [7] fix: modelLabel resolves via the core's O(1) keyField index, not a scan.
|
||||
assert 'keyField: "alias"' in models_src, (
|
||||
"models.js must index by alias (keyField) so modelLabel is an O(1) getByKey"
|
||||
)
|
||||
assert "getByKey(alias)" in models_src, (
|
||||
"modelLabel must resolve via the core's getByKey index (not a per-paint scan)"
|
||||
)
|
||||
assert "_resolveModelLabel" not in _APP_JS.read_text(encoding="utf-8"), (
|
||||
"the ui app must not keep a local _resolveModelLabel (use TurnstoneModels.modelLabel)"
|
||||
)
|
||||
@@ -2730,14 +2753,19 @@ def test_reset_extra_policy_per_cache() -> None:
|
||||
def test_models_cache_exposes_both_server_schemas() -> None:
|
||||
"""models.js must carry ALL default-alias fields — the node server sends
|
||||
default_alias, the console sends coordinator_default_alias, both send
|
||||
judge_default_alias — so each app reads its own, and fold them into the
|
||||
fingerprint so a default-only change still fires onChange (R6)."""
|
||||
judge_default_alias — so each app reads its own (via modelDefaults/captureExtra,
|
||||
which drive the "Default — <alias>" placeholder). The dead onChange
|
||||
subscription (+ its fingerprint fold) was removed: models has no live-render
|
||||
subscriber (the console repaints via its direct models_changed handler), so it
|
||||
exposes no onModelsChange."""
|
||||
src = _MODELS_JS.read_text(encoding="utf-8")
|
||||
for field in ("default_alias", "judge_default_alias", "coordinator_default_alias"):
|
||||
assert field in src, f"models.js must carry {field} for the two server schemas"
|
||||
assert "window.TurnstoneModels" in src, "models.js must install the classic bridge"
|
||||
assert "makeListCache" in src, "models.js must build on the shared list_cache core"
|
||||
assert "fpExtra" in src, "models.js must fold the default aliases into the fingerprint (R6)"
|
||||
assert "onModelsChange" not in src, (
|
||||
"models.js must not expose the dead onModelsChange subscription (no subscribers)"
|
||||
)
|
||||
|
||||
|
||||
def test_skills_cache_returns_raw_rows() -> None:
|
||||
|
||||
@@ -5,16 +5,25 @@ window.onLoginSuccess = function () {
|
||||
if (typeof _refreshHomeComposerVisibility === "function") {
|
||||
_refreshHomeComposerVisibility();
|
||||
}
|
||||
// Re-warm the home-composer skill AND model pickers now that auth has
|
||||
// landed. The initial page-load pass runs before login completes, so
|
||||
// /v1/api/skills and /v1/api/models 401 (fail-open: the caches keep their
|
||||
// empty state); without this re-run the dropdowns stay at their placeholders
|
||||
// until a reload (models used to only recover on a chance models_changed).
|
||||
// Re-warm ALL FOUR home-composer caches now that auth has landed. The initial
|
||||
// page-load pass runs before login completes, so /v1/api/{skills,models,
|
||||
// projects,personas} all 401 (fail-open: the caches keep their empty state);
|
||||
// without this re-run the launcher dropdowns — and the rail's group-by-project +
|
||||
// the saved-coordinator project/persona columns — stay empty until a reload.
|
||||
// {force:true} so a still-in-flight failing pre-auth fetch yields a trailing
|
||||
// AUTHENTICATED refetch rather than coalescing onto the 401 (skills/personas
|
||||
// have no *_changed event to recover otherwise).
|
||||
if (typeof _refreshAndPopulateSkills === "function") {
|
||||
_refreshAndPopulateSkills();
|
||||
_refreshAndPopulateSkills({ force: true });
|
||||
}
|
||||
if (typeof _refreshAndPopulateModels === "function") {
|
||||
_refreshAndPopulateModels();
|
||||
_refreshAndPopulateModels({ force: true });
|
||||
}
|
||||
if (typeof _refreshAndPopulateProjects === "function") {
|
||||
_refreshAndPopulateProjects({ force: true });
|
||||
}
|
||||
if (typeof _refreshAndPopulatePersonas === "function") {
|
||||
_refreshAndPopulatePersonas({ force: true });
|
||||
}
|
||||
// Active-coordinators list is SSE-driven via the console pseudo-node
|
||||
// (#9) — no poller to restart after login. The home-view renderer
|
||||
@@ -1492,7 +1501,7 @@ function _ensureHomeComposerInit() {
|
||||
// the saved-list / rail labels) then repaint the launcher's Persona picker.
|
||||
// Safe when the bridge is absent (module still loading): the picker keeps
|
||||
// its "Default" placeholder, which the server resolves to the kind default.
|
||||
function _refreshAndPopulatePersonas() {
|
||||
function _refreshAndPopulatePersonas(callOpts) {
|
||||
const TP = window.TurnstonePersonas;
|
||||
if (!TP) return;
|
||||
// Paint from the warm cache SYNCHRONOUSLY first so the Persona picker isn't
|
||||
@@ -1501,7 +1510,7 @@ function _refreshAndPopulatePersonas() {
|
||||
// PersonaDropdown preserves a mid-window pick and only applies the kind default
|
||||
// when nothing valid is selected, so the second paint can't clobber a choice.
|
||||
_populateHomePersonaDropdown();
|
||||
TP.refreshPersonas().then(_populateHomePersonaDropdown);
|
||||
TP.refreshPersonas(callOpts).then(_populateHomePersonaDropdown);
|
||||
}
|
||||
|
||||
// Populate the launcher's Persona picker for the ACTIVE kind, preselecting
|
||||
@@ -1532,7 +1541,7 @@ function _populateHomePersonaDropdown() {
|
||||
// rail's group-by-project) then repaint the launcher's Project picker. Safe
|
||||
// when the bridge is absent (project.read denied / module still loading): the
|
||||
// picker simply keeps its "No project" placeholder.
|
||||
function _refreshAndPopulateProjects() {
|
||||
function _refreshAndPopulateProjects(callOpts) {
|
||||
const TP = window.TurnstoneProjects;
|
||||
if (!TP) return;
|
||||
// Sync paint from the warm cache first (the launcher keeps its "No project"
|
||||
@@ -1541,7 +1550,7 @@ function _refreshAndPopulateProjects() {
|
||||
// console launcher intentionally does NOT gate on requireProject() (§8) — the
|
||||
// node create endpoint is the authoritative gate.
|
||||
_populateHomeProjectDropdown();
|
||||
TP.refreshProjects().then(_populateHomeProjectDropdown);
|
||||
TP.refreshProjects(callOpts).then(_populateHomeProjectDropdown);
|
||||
}
|
||||
|
||||
// Populate the launcher's Project picker from the shared cache, preserving the
|
||||
@@ -1731,11 +1740,11 @@ function _mountHomeCoordComposer() {
|
||||
// then refresh-and-repaint to catch a skill created elsewhere. Safe when the
|
||||
// bridge is absent (module still loading / 401 pre-auth — onLoginSuccess re-runs
|
||||
// this once auth lands).
|
||||
function _refreshAndPopulateSkills() {
|
||||
function _refreshAndPopulateSkills(callOpts) {
|
||||
const TS = window.TurnstoneSkills;
|
||||
if (!TS) return;
|
||||
_populateHomeSkillDropdown();
|
||||
TS.refreshSkills().then(_populateHomeSkillDropdown);
|
||||
TS.refreshSkills(callOpts).then(_populateHomeSkillDropdown);
|
||||
}
|
||||
|
||||
// Populate the launcher's Skill picker from the shared cache, preserving the
|
||||
@@ -1746,6 +1755,11 @@ function _populateHomeSkillDropdown() {
|
||||
if (!_homeCoordComposer) return;
|
||||
const TS = window.TurnstoneSkills;
|
||||
if (!TS) return;
|
||||
// Reads the shared skills cache, which is FAIL-OPEN by design: a transient
|
||||
// non-OK refresh keeps the last-known rows rather than blanking to the
|
||||
// placeholder (main blanked via `r.ok ? json : {skills:[]}`; this matches the
|
||||
// projects/personas policy). A since-removed skill is rejected server-side on
|
||||
// launch — a narrow stale-selection window traded for not blanking on a blip.
|
||||
const previous = _homeCoordComposer.getOptionValue("skill");
|
||||
const choices = TS.getSkills().map(function (t) {
|
||||
return {
|
||||
|
||||
@@ -30,12 +30,6 @@ import { authFetch } from "./auth.js";
|
||||
* @param {(row:object)=>any} opts.fpRow per-row fingerprint tuple — the fields
|
||||
* subscribers render, so a refresh that
|
||||
* returns identical data skips the fan-out
|
||||
* @param {(extra:object)=>any} [opts.fpExtra] extra top-level fields to fold
|
||||
* into the fingerprint (e.g. models' default
|
||||
* aliases, so a default-only change still
|
||||
* fires onChange). Omit to fingerprint rows
|
||||
* only (a `captureExtra` value that changes
|
||||
* without a row change then does NOT fire).
|
||||
* @param {(data:object)=>object} [opts.captureExtra] pull extra top-level
|
||||
* response fields into cache state on a
|
||||
* successful refresh (returns the new
|
||||
@@ -64,7 +58,6 @@ export function makeListCache(opts) {
|
||||
const name = opts.name;
|
||||
const keyField = opts.keyField || null;
|
||||
const fpRow = opts.fpRow;
|
||||
const fpExtra = opts.fpExtra || null;
|
||||
const captureExtra = opts.captureExtra || null;
|
||||
const extraDefaults = opts.extraDefaults || null;
|
||||
// Default true: an advisory that gates UI (projects' require_project) must fail
|
||||
@@ -88,10 +81,7 @@ export function makeListCache(opts) {
|
||||
// (it escapes anything inside a string field) and needs no separator
|
||||
// chars; an earlier per-module version joined on raw control bytes,
|
||||
// which made git see the whole file as binary.
|
||||
return JSON.stringify([
|
||||
_cache.map(fpRow),
|
||||
fpExtra ? fpExtra(_extra) : null,
|
||||
]);
|
||||
return JSON.stringify(_cache.map(fpRow));
|
||||
}
|
||||
|
||||
function _setCache(rows) {
|
||||
|
||||
@@ -32,19 +32,12 @@ const _core = makeListCache({
|
||||
url: "/v1/api/models",
|
||||
dataKey: "models",
|
||||
name: "models",
|
||||
// alias is the unique <select> key — index by it so modelLabel() is an O(1)
|
||||
// getByKey rather than a per-paint scan.
|
||||
keyField: "alias",
|
||||
fpRow: function (m) {
|
||||
return [m.alias, m.model];
|
||||
},
|
||||
// Fold the default-alias fields into the fingerprint so a role-alias change
|
||||
// (server emits `models_changed` for it) still fires onChange even when the
|
||||
// model rows themselves are unchanged.
|
||||
fpExtra: function (e) {
|
||||
return [
|
||||
e.default_alias,
|
||||
e.judge_default_alias,
|
||||
e.coordinator_default_alias,
|
||||
];
|
||||
},
|
||||
captureExtra: function (data) {
|
||||
return {
|
||||
default_alias: data.default_alias || "",
|
||||
@@ -113,15 +106,12 @@ export function modelChoices() {
|
||||
|
||||
/** The "alias (model)" label for a model alias, or "" when the alias is empty or
|
||||
* unknown — lets a composer annotate its "Default — <resolved>" placeholder
|
||||
* without re-implementing the format. The models cache has no key index, so
|
||||
* this scans the (small) row list. */
|
||||
* without re-implementing the format. Resolves via the keyField:"alias" index
|
||||
* (getByKey), O(1). */
|
||||
export function modelLabel(alias) {
|
||||
if (!alias) return "";
|
||||
const rows = _core.get();
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].alias === alias) return _fmtLabel(rows[i]);
|
||||
}
|
||||
return "";
|
||||
const row = _core.getByKey(alias);
|
||||
return row ? _fmtLabel(row) : "";
|
||||
}
|
||||
|
||||
/** The resolved default aliases the local server reported:
|
||||
@@ -134,12 +124,10 @@ export function modelDefaults() {
|
||||
return _core.extra();
|
||||
}
|
||||
|
||||
/** Subscribe to post-refresh changes. Idempotent. (No composer subscribes
|
||||
* today — the console repaints via its direct `models_changed` handler — but
|
||||
* the hook is exposed for parity with the other data layers.) */
|
||||
export function onModelsChange(cb) {
|
||||
_core.onChange(cb);
|
||||
}
|
||||
// No onChange subscription is exposed (unlike projects.js / personas.js, whose
|
||||
// onChange is consumed by rail.js): the models cache has no live-render consumer
|
||||
// — the console repaints on `models_changed` via its own direct handler, the ui
|
||||
// composers repaint on open. Omitted rather than exposed-and-unused.
|
||||
|
||||
// Classic (non-module) app.js bundles reach the data layer through this bridge.
|
||||
window.TurnstoneModels = {
|
||||
@@ -150,5 +138,4 @@ window.TurnstoneModels = {
|
||||
modelChoices: modelChoices,
|
||||
modelLabel: modelLabel,
|
||||
modelDefaults: modelDefaults,
|
||||
onModelsChange: onModelsChange,
|
||||
};
|
||||
|
||||
@@ -39,8 +39,8 @@ const _core = makeListCache({
|
||||
* Failures are recorded (see {@link personasError}) and warned, rather
|
||||
* than silently masqueraded as "no personas".
|
||||
*/
|
||||
export function refreshPersonas() {
|
||||
return _core.refresh();
|
||||
export function refreshPersonas(callOpts) {
|
||||
return _core.refresh(callOpts);
|
||||
}
|
||||
|
||||
/** Cached persona rows (empty array until the first refresh resolves). */
|
||||
|
||||
@@ -23,10 +23,10 @@ import { makeListCache } from "./list_cache.js";
|
||||
// The require_project advisory rides the same /v1/api/projects response as an
|
||||
// extra top-level field. It fails OPEN to false: a stale-true value would make
|
||||
// the composer hide options on a transient error, so `extraDefaults` resets it
|
||||
// on every refresh failure and it seeds false before the first refresh. It is
|
||||
// deliberately NOT in the fingerprint (no fpExtra) — a require_project-only
|
||||
// toggle with an unchanged project list should not force a rail rebuild; the
|
||||
// composers re-read it synchronously on their next open.
|
||||
// on every refresh failure and it seeds false before the first refresh. The
|
||||
// fingerprint is the project rows only, so a require_project-only toggle (with an
|
||||
// unchanged project list) does NOT force a rail rebuild; the composers re-read it
|
||||
// synchronously on their next open.
|
||||
const _core = makeListCache({
|
||||
url: "/v1/api/projects",
|
||||
dataKey: "projects",
|
||||
@@ -53,8 +53,8 @@ const _core = makeListCache({
|
||||
* a half-open picker. A failure is recorded (see {@link projectsError})
|
||||
* and warned, rather than silently masqueraded as an empty project list.
|
||||
*/
|
||||
export function refreshProjects() {
|
||||
return _core.refresh();
|
||||
export function refreshProjects(callOpts) {
|
||||
return _core.refresh(callOpts);
|
||||
}
|
||||
|
||||
/** Cached project rows (empty array until the first refresh resolves). */
|
||||
|
||||
@@ -36,11 +36,13 @@ const _core = makeListCache({
|
||||
/**
|
||||
* Fetch /v1/api/skills into the cache. Resolves to the row list and NEVER
|
||||
* rejects — a failed/forbidden fetch keeps the prior cache (a picker never
|
||||
* blanks). Recorded (see {@link skillsError}) and warned rather than
|
||||
* masqueraded as "no skills".
|
||||
* blanks). Recorded (see {@link skillsError}) rather than masqueraded as "no
|
||||
* skills". Pass `{force:true}` to force a fresh fetch that converges to the
|
||||
* latest even mid-flight (onLoginSuccess uses this to recover a failed pre-auth
|
||||
* warm — skills has no *_changed event to recover otherwise).
|
||||
*/
|
||||
export function refreshSkills() {
|
||||
return _core.refresh();
|
||||
export function refreshSkills(callOpts) {
|
||||
return _core.refresh(callOpts);
|
||||
}
|
||||
|
||||
/** Cached skill rows (`{name, is_default, origin, ...}`; empty until the
|
||||
@@ -61,10 +63,9 @@ export function skillsError() {
|
||||
return _core.error();
|
||||
}
|
||||
|
||||
/** Subscribe to post-refresh changes. Idempotent. */
|
||||
export function onSkillsChange(cb) {
|
||||
_core.onChange(cb);
|
||||
}
|
||||
// No onChange subscription is exposed (unlike projects.js / personas.js): the
|
||||
// skills cache has no live-render consumer — the composers repaint on open.
|
||||
// Omitted rather than exposed-and-unused.
|
||||
|
||||
// Classic (non-module) app.js bundles reach the data layer through this bridge.
|
||||
window.TurnstoneSkills = {
|
||||
@@ -72,5 +73,4 @@ window.TurnstoneSkills = {
|
||||
getSkills: getSkills,
|
||||
skillsLoaded: skillsLoaded,
|
||||
skillsError: skillsError,
|
||||
onSkillsChange: onSkillsChange,
|
||||
};
|
||||
|
||||
+43
-23
@@ -105,6 +105,12 @@ setInterval(pollHealth, 30000);
|
||||
// ===========================================================================
|
||||
|
||||
window.onLoginSuccess = function () {
|
||||
// Deferred (follow-up): a cold in-place login leaves the dashboard composer
|
||||
// caches (models/skills/projects/personas) warmed pre-auth as empty (401 ->
|
||||
// fail-open) until the Dashboard pane is re-focused (loadDashboard ->
|
||||
// _loadDashboardOptionsLists) or the page reloads. The CONSOLE force-re-warms
|
||||
// all four in its onLoginSuccess; the ui relies on the re-focus repaint and is
|
||||
// NOT force-warmed here (out of the composer-cache scope) — revisit if it bites.
|
||||
initWorkstreams();
|
||||
};
|
||||
|
||||
@@ -353,9 +359,13 @@ function showNewWsModal(forkFromWsId) {
|
||||
_paintModelSelects(modelSelect, judgeSelect, { freshOnOpen: true });
|
||||
}
|
||||
|
||||
// Skill picker — paint fresh-on-open from the warm cache, then refresh.
|
||||
// Skill picker — hidden for a fork (inherited + submit-gated), so skip its
|
||||
// paint too (no wasted /v1/api/skills fetch + hidden-select rebuild); a fresh
|
||||
// create paints fresh-on-open from the warm cache, then refreshes.
|
||||
const tplSelect = document.getElementById("new-ws-skill");
|
||||
_paintSkillSelect(tplSelect, { freshOnOpen: true });
|
||||
if (!_forkFromWsId) {
|
||||
_paintSkillSelect(tplSelect, { freshOnOpen: true });
|
||||
}
|
||||
|
||||
// Project picker — populated from the shared projects cache, refreshed on
|
||||
// open. Fresh creates SHOW it; forks HIDE it — a fork's project is its
|
||||
@@ -388,15 +398,11 @@ function showNewWsModal(forkFromWsId) {
|
||||
const personaSelect = document.getElementById("new-ws-persona");
|
||||
if (personaLabel) personaLabel.hidden = !!_forkFromWsId;
|
||||
if (personaSelect) personaSelect.hidden = !!_forkFromWsId;
|
||||
if (personaSelect && !_forkFromWsId && window.TurnstonePersonas) {
|
||||
// Fresh-on-open sync paint (renders the kind default; the reused dialog has
|
||||
// no prior pick to carry over), then refresh-and-repaint. The async paint
|
||||
// passes fresh:false so it preserves a mid-window pick and only re-applies
|
||||
// the kind default when nothing valid is selected.
|
||||
_populatePersonaSelect(personaSelect, { fresh: true });
|
||||
window.TurnstonePersonas.refreshPersonas().then(function () {
|
||||
_populatePersonaSelect(personaSelect, { fresh: false });
|
||||
});
|
||||
// Fresh-on-open (the reused dialog has no prior pick); the async repaint
|
||||
// preserves a mid-window pick and re-applies the kind default only when nothing
|
||||
// valid is selected. Shared with the dashboard via _paintPersonaSelect.
|
||||
if (!_forkFromWsId) {
|
||||
_paintPersonaSelect(personaSelect, { freshOnOpen: true });
|
||||
}
|
||||
|
||||
document.getElementById("new-ws-name").value = "";
|
||||
@@ -566,6 +572,21 @@ function _paintSkillSelect(sel, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// Persona twin of _paintModelSelects (fourth composer picker on the same shape,
|
||||
// so the modal + dashboard don't maintain the persona sync-then-refresh dance
|
||||
// two different ways). _populatePersonaSelect keeps the kind default when
|
||||
// nothing valid is selected, so the async fresh:false repaint can't clobber a
|
||||
// mid-window pick.
|
||||
function _paintPersonaSelect(sel, opts) {
|
||||
const freshOnOpen = !!(opts && opts.freshOnOpen);
|
||||
_populatePersonaSelect(sel, { fresh: freshOnOpen });
|
||||
if (window.TurnstonePersonas) {
|
||||
window.TurnstonePersonas.refreshPersonas().then(function () {
|
||||
_populatePersonaSelect(sel, { fresh: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the model + judge-model <select>s from the shared models cache. One list
|
||||
// feeds BOTH selects with the same "alias (model)" labels; each placeholder is
|
||||
// annotated with the server-resolved default alias ("Default — gpt-5"), resolved
|
||||
@@ -728,8 +749,13 @@ function submitNewWs() {
|
||||
const initEl = document.getElementById("new-ws-initial-message");
|
||||
const initial_message = initEl ? initEl.value.trim() : "";
|
||||
if (name) body.name = name;
|
||||
// Forks inherit their source's model + judge (the selects are hidden for a
|
||||
// fork), so never override them — matches the skill/persona/project fork guards.
|
||||
// Forks DELIBERATELY inherit their source's model + judge: the selects are
|
||||
// hidden for a fork (showNewWsModal) and never sent here — matching the
|
||||
// skill/persona/project fork guards. A fork resumes the source session
|
||||
// (resume_ws), which already carries its model, so there is intentionally no
|
||||
// fork model/judge override. Do NOT drop these !_forkFromWsId guards without
|
||||
// also un-hiding the selects (a bare gate-removal ships a hidden-select's stale
|
||||
// value).
|
||||
if (model && !_forkFromWsId) body.model = model;
|
||||
if (judge_model && !_forkFromWsId) body.judge_model = judge_model;
|
||||
if (skill && !_forkFromWsId) body.skill = skill;
|
||||
@@ -1514,17 +1540,11 @@ function _loadDashboardOptionsLists() {
|
||||
const projHint = projLabel ? projLabel.querySelector(".label-hint") : null;
|
||||
_paintProjectPicker(projSel, projHint, { fork: false });
|
||||
|
||||
// Persona picker — same paint-from-cache-then-refresh policy; kind default
|
||||
// preselected so a zero-touch launch behaves exactly like today.
|
||||
// Persona picker — via the shared wrapper; the dashboard is a persistent panel
|
||||
// so freshOnOpen:false (preserve a pick across a repaint), kind default when
|
||||
// nothing valid is selected.
|
||||
const personaSel = document.getElementById("dashboard-persona");
|
||||
if (personaSel && window.TurnstonePersonas) {
|
||||
// Sync paint first, then refresh-and-repaint; the helper preserves a mid-
|
||||
// window pick and only applies the kind default when nothing valid is chosen.
|
||||
_populatePersonaSelect(personaSel);
|
||||
window.TurnstonePersonas.refreshPersonas().then(function () {
|
||||
_populatePersonaSelect(personaSel);
|
||||
});
|
||||
}
|
||||
_paintPersonaSelect(personaSel, { freshOnOpen: false });
|
||||
}
|
||||
|
||||
// localStorage key for the dashboard composer's Options-panel disclosure
|
||||
|
||||
Reference in New Issue
Block a user