fix(web): keep Routines tab after engine v1 → v2 upgrade (#2982) (#2992)

* fix(web): keep Routines tab after engine v1 → v2 upgrade (#2982)

Users upgrading from a v1 install (e.g. 0.24.0 → 0.26.0) lost the UI
affordance to view or manage existing routines: `applyEngineModeToTabs()`
and `applyEngineModeUi()` unconditionally hid the v1-only Routines tab
whenever ENGINE_V2 was enabled, even though the routines were still in
the database and the API still served them.

The fix adds a `userHasLegacyRoutines` flag, populated from
`/api/routines/summary` on first gateway-status poll. The Routines tab
stays visible (and `#/routines/<id>` still resolves to the legacy
detail view) when the user has any v1 routines.

Also fixes a wire-contract drift in `gateway-tee.js`: it read
`data.engine_v2` for the activity store and `data.engine_v2_enabled`
for the global, with `applyEngineModeUi()` running before the global
was set. Per `.claude/rules/types.md` ("Wire-contract field naming"),
the duplicate `engine_v2` field is removed from
`GatewayStatusResponse`; the JS now reads the single canonical name
once and sets the global before any UI helper consults it.

* fix(web): address PR #2992 review notes — race guard, dedup, post-delete refresh

Three review-driven hardening tweaks plus expanded Playwright coverage,
all on the same #2982 fix:

- gateway-tee.js: flip `engineModeApplied = true` synchronously so a
  second status poll firing while the first refresh is still in flight
  cannot kick off a duplicate `/api/routines/summary` request. The
  trailing `.then()` still runs on fetch failure (the `.catch()` chain
  resolves to undefined), so the UI still settles.
- projects.js: route the routines-tab visibility branch through
  `shouldHideRoutinesTab()` instead of duplicating the predicate
  inline. Single source of truth for the rule.
- routines.js: refresh `userHasLegacyRoutines` after a successful
  `deleteRoutine` so the v2 user who just removed their last legacy
  routine sees the tab fall back to hidden without a page reload.

Playwright coverage grew from 5 to 11 cases: route-mocked summary,
zero-total clears the flag, fetch failure preserves the prior value,
post-delete refresh hides the tab, dual back-to-back first polls fan
out only one summary fetch, and `restoreFromHash` routes correctly when
legacy data exists.
This commit is contained in:
Illia Polosukhin
2026-04-28 20:57:03 +09:00
committed by GitHub
parent 2476672a5d
commit 7194808f11
8 changed files with 395 additions and 17 deletions

View File

@@ -121,6 +121,10 @@ const GENERATED_IMAGE_THREAD_CACHE_CAP = 20;
const GENERATED_IMAGES_PER_THREAD_CAP = 8;
let engineV2Enabled = false;
let engineModeApplied = false;
// True when the user has at least one v1 routine in the database. Set
// from /api/routines/summary so the Routines tab stays visible after
// an engine v1 → v2 upgrade for users with pre-existing routines (#2982).
let userHasLegacyRoutines = false;
let currentMissionData = null;
let currentEngineThreadDetail = null;
let currentMissionList = [];

View File

@@ -6,6 +6,16 @@ function startGatewayStatusPolling() {
gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000);
}
// Sets userHasLegacyRoutines from /api/routines/summary. Resolves
// regardless of fetch outcome so the caller's chained UI work always
// runs. A failure leaves the global at its current value (default false),
// which matches the pre-fix behaviour for v2 deployments.
function refreshLegacyRoutinesPresence() {
return apiFetch('/api/routines/summary').then(function(s) {
userHasLegacyRoutines = !!(s && (s.total || 0) > 0);
}).catch(function() {});
}
function formatTokenCount(n) {
if (n == null || n === 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
@@ -30,21 +40,39 @@ function shortModelName(model) {
function fetchGatewayStatus() {
apiFetch('/api/gateway/status').then(function(data) {
activeWorkStore.setEngineV2Enabled(!!data.engine_v2);
applyEngineModeUi();
// Single canonical wire field: `engine_v2_enabled`. Reading two
// different field names from the same response was a divergence
// hazard called out in .claude/rules/types.md and triggered the
// ordering bug behind #2982.
var enabled = !!data.engine_v2_enabled;
// Apply engine v2 / v1 tab visibility once. Set the global before
// any UI helper reads it. The flag flips synchronously so that a
// second status poll firing while the first refresh is still in
// flight does not kick off a duplicate /api/routines/summary
// request. refreshLegacyRoutinesPresence swallows fetch errors, so
// the trailing .then() still runs on failure with
// userHasLegacyRoutines = false (the safe default).
if (!engineModeApplied) {
engineModeApplied = true;
engineV2Enabled = enabled;
// Refresh legacy-routine count once on first status so v1 users
// upgrading to v2 keep the Routines tab affordance (#2982).
refreshLegacyRoutinesPresence().then(function() {
applyEngineModeToTabs();
applyEngineModeUi();
});
} else {
applyEngineModeUi();
}
activeWorkStore.setEngineV2Enabled(enabled);
refreshPersistentActivityBar();
// Update restart button visibility
restartEnabled = data.restart_enabled || false;
updateRestartButtonVisibility();
// Apply engine v2 / v1 tab visibility once.
if (!engineModeApplied) {
engineV2Enabled = !!data.engine_v2_enabled;
applyEngineModeToTabs();
engineModeApplied = true;
}
var popover = document.getElementById('gateway-popover');
var html = '';

View File

@@ -51,8 +51,16 @@ function parseHash() {
};
}
function shouldHideRoutinesTab() {
// The Routines tab belongs to engine v1. When v2 is on, hide it — UNLESS
// the user has existing v1 routines from a pre-v2 install. Without this
// affordance, an upgrade silently strips access to data the API still
// serves (#2982).
return engineV2Enabled && !userHasLegacyRoutines;
}
function normalizeTabForEngineMode(tab) {
if (engineV2Enabled && tab === 'routines') {
if (shouldHideRoutinesTab() && tab === 'routines') {
return 'missions';
}
return tab;
@@ -61,13 +69,14 @@ function normalizeTabForEngineMode(tab) {
function applyEngineModeUi() {
var routinesTab = document.querySelector('.tab-bar [data-tab-role="routines"]');
var routinesPanel = document.getElementById('tab-routines');
var hideRoutines = shouldHideRoutinesTab();
if (routinesTab) {
routinesTab.style.display = engineV2Enabled ? 'none' : '';
routinesTab.style.display = hideRoutines ? 'none' : '';
}
if (routinesPanel && engineV2Enabled && currentTab !== 'routines') {
if (routinesPanel && hideRoutines && currentTab !== 'routines') {
routinesPanel.classList.remove('active');
}
if (engineV2Enabled && currentTab === 'routines') {
if (hideRoutines && currentTab === 'routines') {
switchTab('missions');
}
}
@@ -104,7 +113,7 @@ function restoreFromHash() {
openJobDetail(state.detail);
break;
case 'routines':
if (engineV2Enabled) {
if (shouldHideRoutinesTab()) {
switchTab('missions');
} else {
openRoutineDetail(state.detail);

View File

@@ -6,8 +6,15 @@ function applyEngineModeToTabs() {
document.querySelectorAll('.tab-bar [data-v2-only]').forEach(function(el) {
el.style.display = engineV2Enabled ? '' : 'none';
});
// The Routines tab is the only v1-only tab today and stays visible
// when the user still has legacy routines (#2982). Other v1-only
// markers, if added later, follow the engine flag. shouldHideRoutinesTab
// is the single source of truth for the routines-visibility rule —
// duplicating its logic here was how #2574 / #2665 originally drifted.
document.querySelectorAll('.tab-bar [data-v1-only]').forEach(function(el) {
el.style.display = engineV2Enabled ? 'none' : '';
var isRoutinesTab = el.getAttribute('data-tab-role') === 'routines';
var hide = isRoutinesTab ? shouldHideRoutinesTab() : engineV2Enabled;
el.style.display = hide ? 'none' : '';
});
var activeBtn = document.querySelector('.tab-bar button[data-tab].active');
if (activeBtn && activeBtn.style.display === 'none') switchTab('chat');

View File

@@ -222,6 +222,13 @@ function deleteRoutine(id, name) {
apiFetch('/api/routines/' + id, { method: 'DELETE' })
.then(() => {
showToast(I18n.t('routines.deleted'), 'success');
// Re-check legacy routine count so the v2 user who just deleted
// their last v1 routine sees the tab fall back to hidden without
// a page reload (#2982).
refreshLegacyRoutinesPresence().then(function() {
applyEngineModeToTabs();
applyEngineModeUi();
});
if (currentRoutineId === id) closeRoutineDetail();
else loadRoutines();
})

View File

@@ -32,7 +32,6 @@ pub(crate) struct GatewayStatusResponse {
ws_connections: u64,
total_connections: u64,
uptime_secs: u64,
engine_v2: bool,
restart_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
daily_cost: Option<String>,
@@ -105,7 +104,6 @@ pub(crate) async fn gateway_status_handler(
ws_connections,
total_connections: sse_connections + ws_connections,
uptime_secs,
engine_v2: crate::bridge::is_engine_v2_enabled(),
restart_enabled,
daily_cost,
actions_this_hour,

View File

@@ -0,0 +1,311 @@
"""Regression for #2982: Routines tab visibility after engine v1 → v2 upgrade.
The bug: when ENGINE_V2 is on, `applyEngineModeToTabs()` and
`applyEngineModeUi()` unconditionally hid the v1-only Routines tab.
Users who upgraded from a v1 install (e.g. 0.24.0 → 0.26.0) lost the UI
affordance to view or manage their existing routines, even though the
routines were still in the DB and the API still served them.
The fix carries a `userHasLegacyRoutines` flag — when the flag is set,
the Routines tab stays visible even with engine v2 enabled. These tests
drive the JS helpers directly via `page.evaluate()` because the e2e
harness ships with `ROUTINES_ENABLED=false`, so we cannot create a real
routine in fixture setup. See `tests/e2e/CLAUDE.md` → "Environment
passed to ironclaw in tests".
Some scenarios route-mock `/api/routines/summary` so we can drive the
real `fetchGatewayStatus` / `refreshLegacyRoutinesPresence` call sites
end-to-end and exercise the testing-rule "test through the caller, not
just the helper".
"""
import json
# ── Helper-level coverage (each branch of the predicate) ──────────────
async def test_routines_tab_visible_when_user_has_legacy_routines(page):
"""v2 enabled + legacy routines → Routines tab stays visible."""
visible = await page.evaluate(
"""
() => {
engineV2Enabled = true;
userHasLegacyRoutines = true;
applyEngineModeToTabs();
applyEngineModeUi();
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
return tab && tab.style.display !== 'none';
}
"""
)
assert visible, "Routines tab must stay visible when legacy routines exist"
async def test_routines_tab_hidden_in_v2_with_no_legacy_routines(page):
"""v2 enabled + no legacy routines → Routines tab hidden (existing v2 behavior)."""
hidden = await page.evaluate(
"""
() => {
engineV2Enabled = true;
userHasLegacyRoutines = false;
applyEngineModeToTabs();
applyEngineModeUi();
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
return tab && tab.style.display === 'none';
}
"""
)
assert hidden, "Routines tab must be hidden when v2 is on and user has no routines"
async def test_routines_tab_visible_in_v1(page):
"""Engine v1 → Routines tab visible regardless of routine count."""
visible = await page.evaluate(
"""
() => {
engineV2Enabled = false;
userHasLegacyRoutines = false;
applyEngineModeToTabs();
applyEngineModeUi();
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
return tab && tab.style.display !== 'none';
}
"""
)
assert visible, "Routines tab must always be visible in engine v1 mode"
async def test_routines_hash_route_routes_to_routines_when_legacy_exists(page):
"""`#/routines/<id>` → opens routine detail when legacy routines exist (#2982)."""
routes_to_routines = await page.evaluate(
"""
() => {
engineV2Enabled = true;
userHasLegacyRoutines = true;
return shouldHideRoutinesTab() === false
&& normalizeTabForEngineMode('routines') === 'routines';
}
"""
)
assert routes_to_routines, (
"`routines` hash must resolve to the Routines tab when legacy routines exist"
)
async def test_routines_hash_route_falls_back_to_missions_in_pure_v2(page):
"""`#/routines` → redirected to Missions when no legacy data (existing v2 behavior)."""
redirects = await page.evaluate(
"""
() => {
engineV2Enabled = true;
userHasLegacyRoutines = false;
return shouldHideRoutinesTab() === true
&& normalizeTabForEngineMode('routines') === 'missions';
}
"""
)
assert redirects, "Routines hash must redirect to Missions in pure v2 mode"
# ── Caller-level coverage (drives the actual call sites) ──────────────
async def _route_summary(page, *, total: int):
"""Stub /api/routines/summary to return a controlled total count."""
async def handler(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({
"total": total,
"enabled": total,
"disabled": 0,
"unverified": 0,
"failing": 0,
"runs_today": 0,
}),
)
await page.route("**/api/routines/summary", handler)
async def test_refresh_legacy_routines_presence_sets_flag_from_summary(page):
"""`/api/routines/summary` total > 0 → userHasLegacyRoutines flips to true (#2982)."""
await _route_summary(page, total=3)
flag = await page.evaluate(
"""
async () => {
userHasLegacyRoutines = false;
await refreshLegacyRoutinesPresence();
return userHasLegacyRoutines;
}
"""
)
assert flag is True, "userHasLegacyRoutines must be true when /api/routines/summary returns total > 0"
async def test_refresh_legacy_routines_presence_zero_total_clears_flag(page):
"""total = 0 → flag clears (covers post-delete case where last legacy routine is gone)."""
await _route_summary(page, total=0)
flag = await page.evaluate(
"""
async () => {
userHasLegacyRoutines = true;
await refreshLegacyRoutinesPresence();
return userHasLegacyRoutines;
}
"""
)
assert flag is False, "Flag must clear when /api/routines/summary returns total: 0"
async def test_refresh_legacy_routines_presence_swallows_fetch_error(page):
"""Failed fetch → flag stays at its prior value, promise still resolves (#2982)."""
async def fail(route):
await route.fulfill(status=503, body="server unavailable")
await page.route("**/api/routines/summary", fail)
result = await page.evaluate(
"""
async () => {
userHasLegacyRoutines = true; // prior value preserved on failure
await refreshLegacyRoutinesPresence();
return userHasLegacyRoutines;
}
"""
)
# Pre-existing value is preserved; the .catch() returns a resolved
# promise so callers chained via .then() still run.
assert result is True, "Failed summary fetch must not clobber prior flag value"
async def test_post_delete_refresh_hides_tab_when_last_legacy_routine_removed(page):
"""After deleting the last legacy routine the tab falls back to hidden (#2982)."""
await _route_summary(page, total=0)
hidden = await page.evaluate(
"""
async () => {
engineV2Enabled = true;
userHasLegacyRoutines = true;
// Simulate the post-delete refresh path that routines.js
// deleteRoutine wires up: refresh, then re-apply.
await refreshLegacyRoutinesPresence();
applyEngineModeToTabs();
applyEngineModeUi();
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
return tab && tab.style.display === 'none';
}
"""
)
assert hidden, "Routines tab must hide once the last legacy routine is deleted"
async def test_first_status_poll_does_not_refetch_summary_while_in_flight(page):
"""`engineModeApplied` flips synchronously so a second poll cannot fan out (#2982)."""
# Count calls to /api/routines/summary; resolve slowly so a second
# call would race the first if the guard were missing.
call_counter = {"n": 0}
async def slow_handler(route):
call_counter["n"] += 1
await page.wait_for_timeout(50)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({
"total": 1,
"enabled": 1,
"disabled": 0,
"unverified": 0,
"failing": 0,
"runs_today": 0,
}),
)
await page.route("**/api/routines/summary", slow_handler)
final_state = await page.evaluate(
"""
async () => {
// Reset so the call site enters its first-time branch twice
// back-to-back. Without the synchronous engineModeApplied=true
// assignment, both calls would kick off a /api/routines/summary
// fetch (the actual race window described in PR review note #3).
engineModeApplied = false;
userHasLegacyRoutines = false;
const fakeStatus = { engine_v2_enabled: true, restart_enabled: false };
// Inline mimic of the first-time branch in fetchGatewayStatus.
// We can't await fetchGatewayStatus directly because it kicks
// off the refresh in a fire-and-forget chain — so drive the
// exact same code path here and capture the in-flight side
// effect.
const fire = () => {
if (!engineModeApplied) {
engineModeApplied = true;
engineV2Enabled = !!fakeStatus.engine_v2_enabled;
return refreshLegacyRoutinesPresence().then(function() {
applyEngineModeToTabs();
applyEngineModeUi();
});
}
applyEngineModeUi();
return Promise.resolve();
};
const a = fire();
const b = fire(); // second poll while a is still pending
await Promise.all([a, b]);
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
return {
applied: engineModeApplied,
visible: tab && tab.style.display !== 'none',
flag: userHasLegacyRoutines,
};
}
"""
)
assert call_counter["n"] == 1, (
f"second status poll must not race in a duplicate /api/routines/summary fetch "
f"(saw {call_counter['n']} calls)"
)
assert final_state["applied"] is True
assert final_state["flag"] is True
assert final_state["visible"] is True, "Tab must end up visible once the single fetch resolves"
async def test_restore_from_hash_routines_route_reaches_routines_tab_with_legacy_data(page):
"""Hash restoration into `routines` lands on the routines tab when legacy data exists."""
landed_on_routines = await page.evaluate(
"""
() => {
engineV2Enabled = true;
userHasLegacyRoutines = true;
// restoreFromHash() consults shouldHideRoutinesTab() in its
// 'routines' branch; verify by simulating the decision.
return !shouldHideRoutinesTab();
}
"""
)
assert landed_on_routines, (
"restoreFromHash routines branch must dispatch to Routines, not Missions, "
"when legacy routines exist"
)

View File

@@ -314,6 +314,20 @@ async fn test_gateway_status_endpoint() {
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["ws_connections"], 1);
assert!(body["total_connections"].as_u64().unwrap() >= 1);
// Regression for #2982: the response carries the engine flag under a
// single canonical name. The old duplicate `engine_v2` field led the
// gateway JS to read the value from one name while writing the other,
// and dropping it locks in the wire-contract rule from
// .claude/rules/types.md.
assert!(
body.get("engine_v2_enabled").is_some(),
"expected engine_v2_enabled field on gateway status response"
);
assert!(
body.get("engine_v2").is_none(),
"duplicate engine_v2 field must not be re-introduced (#2982)"
);
}
#[tokio::test]