Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ concurrency:
jobs:
site:
name: Documentation links, language, and browser accessibility
runs-on: ${{ vars.HEAVY_RUNNER || 'ubuntu-latest' }}
runs-on: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 20
permissions:
contents: read
Expand Down
344 changes: 241 additions & 103 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@ jobs:
exact-release-gate:
name: Exact release artifact
needs: python-tests
runs-on: ${{ vars.HEAVY_RUNNER || 'ubuntu-latest' }}
runs-on: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 90
permissions:
contents: read
env:
PLAYWRIGHT_WORKERS: "4"
VERIFY_RELEASE_JOBS: ${{ vars.HEAVY_RUNNER && '4' || '1' }}
VERIFY_RELEASE_JOBS: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }}
Comment on lines 140 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Playwright parallelism no longer matches the selected runner class. Both workflows now select vars.HEAVY_RUNNER only for refs/heads/main and fall back to ubuntu-latest elsewhere, but each still starts 4 Playwright workers. Pull request runs oversubscribe a 2-vCPU runner and produce timeouts and flaky browser tests. .github/workflows/release.yml already ties the worker count to the same condition.

  • .github/workflows/verify.yml#L140-L142: set PLAYWRIGHT_WORKERS to ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }}, matching the adjacent VERIFY_RELEASE_JOBS expression.
  • .github/workflows/documentation.yml#L19-L19: replace the fixed PLAYWRIGHT_WORKERS: "4" value at Line 24 with the same gated expression.
📍 Affects 2 files
  • .github/workflows/verify.yml#L140-L142 (this comment)
  • .github/workflows/documentation.yml#L19-L19
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/verify.yml around lines 140 - 142, Update
PLAYWRIGHT_WORKERS in .github/workflows/verify.yml lines 140-142 to use the same
gated expression as VERIFY_RELEASE_JOBS, yielding 4 workers only on main with
vars.HEAVY_RUNNER and 1 otherwise; update the fixed PLAYWRIGHT_WORKERS value in
.github/workflows/documentation.yml line 19 to the same expression.

LEGION_DOCS_URL: ${{ vars.LEGION_DOCS_URL }}
LEGION_DOCS_VERSION: ${{ vars.LEGION_DOCS_VERSION }}
steps:
Expand Down
211 changes: 180 additions & 31 deletions operator-app/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type Theme = 'light' | 'dark';

const themeStorageKey = 'picogrid-ecn-operator-theme';
const viewIdentityStorageKey = 'picogrid-ecn-operator-view-id';
const viewGenerationStorageKey = 'picogrid-ecn-operator-view-generation';
const viewRetirementStorageKey = 'picogrid-ecn-operator-view-retirement';
const duplicateViewCloseCode = 1013;
const duplicateViewCloseReason = 'operator view identity is already in use';
const duplicateViewRetryLimit = 3;
Expand All @@ -58,23 +60,61 @@ const canonicalUuidPattern =
interface InitialViewIdentity {
id: string;
persistent: boolean;
generation: string | null;
retirementPending: boolean;
}

function initialViewIdentity(): InitialViewIdentity {
const generated = window.crypto.randomUUID();
try {
const stored = window.sessionStorage.getItem(viewIdentityStorageKey);
if (stored === null) {
const storedId = window.sessionStorage.getItem(viewIdentityStorageKey);
if (storedId === null || !canonicalUuidPattern.test(storedId)) {
window.sessionStorage.setItem(viewIdentityStorageKey, generated);
return { id: generated, persistent: true };
window.sessionStorage.removeItem(viewGenerationStorageKey);
window.sessionStorage.removeItem(viewRetirementStorageKey);
return {
id: generated,
generation: null,
retirementPending: false,
persistent: true,
};
}
if (!canonicalUuidPattern.test(stored)) {
window.sessionStorage.setItem(viewIdentityStorageKey, generated);
return { id: generated, persistent: true };
const storedGeneration = window.sessionStorage.getItem(viewGenerationStorageKey);
const retirementGeneration = window.sessionStorage.getItem(viewRetirementStorageKey);
if (
storedGeneration !== null &&
canonicalUuidPattern.test(storedGeneration) &&
retirementGeneration !== null &&
canonicalUuidPattern.test(retirementGeneration) &&
retirementGeneration.toLowerCase() === storedGeneration.toLowerCase()
) {
window.sessionStorage.setItem(
viewRetirementStorageKey,
storedGeneration.toLowerCase(),
);
return {
id: storedId.toLowerCase(),
generation: storedGeneration.toLowerCase(),
retirementPending: true,
persistent: true,
};
}
return { id: stored.toLowerCase(), persistent: true };
window.sessionStorage.setItem(viewIdentityStorageKey, generated);
window.sessionStorage.removeItem(viewGenerationStorageKey);
window.sessionStorage.removeItem(viewRetirementStorageKey);
return {
id: generated,
generation: null,
retirementPending: false,
persistent: true,
};
} catch {
return { id: generated, persistent: false };
return {
id: generated,
generation: null,
retirementPending: false,
persistent: false,
};
}
}

Expand Down Expand Up @@ -170,7 +210,12 @@ let deferredStrandedPreparation: {
let socket: WebSocket | null = null;
let recoverySocket: WebSocket | null = null;
let activeViewId = initialBrowserView.id;
let activeViewGeneration = window.crypto.randomUUID();
let activeViewGeneration =
initialBrowserView.generation ?? window.crypto.randomUUID();
let activeViewAcceptedByDocument = false;
let retirementRequired = initialBrowserView.retirementPending;
let postRetirementConflict = false;
let acknowledgedRetirementGeneration: string | null = null;
let viewIdentityPersistent = initialBrowserView.persistent;
let viewGeneration = 0;
let preparationGeneration = 0;
Expand Down Expand Up @@ -411,10 +456,10 @@ function setReviewState(status: PreparationStatus): void {
if (review) review.status = status;
confirmDialog.dataset.state = status;
const reviewIsActive = status === 'review';
reconnectViewButton.disabled = status !== 'review';
reconnectViewButton.disabled = status !== 'review' || postRetirementConflict;
cancelConfirmButton.disabled = !reviewIsActive;
confirmButton.disabled = !reviewIsActive || !confirmCheck.checked || !preparedTaskIsEligible();
recoverViewButton.disabled = status !== 'stranded';
recoverViewButton.disabled = status !== 'stranded' || postRetirementConflict;
confirmInvalidation.textContent =
status === 'invalidating'
? 'Task confirmation or prepared-task invalidation is still in progress…'
Expand Down Expand Up @@ -1057,7 +1102,63 @@ function render(): void {
);
}

function forgetPersistedViewIdentity(): void {
viewIdentityPersistent = false;
for (const key of [
viewIdentityStorageKey,
viewGenerationStorageKey,
viewRetirementStorageKey,
]) {
try {
window.sessionStorage.removeItem(key);
} catch {
// The current document remains fail-closed even when storage cannot be cleared.
}
}
}

function acceptViewGeneration(generation: string): void {
activeViewGeneration = generation;
activeViewAcceptedByDocument = true;
retirementRequired = false;
acknowledgedRetirementGeneration = null;
postRetirementConflict = false;
if (!viewIdentityPersistent) return;
try {
window.sessionStorage.setItem(viewGenerationStorageKey, generation);
window.sessionStorage.removeItem(viewRetirementStorageKey);
} catch {
forgetPersistedViewIdentity();
}
}

function preserveAcceptedViewForRetirement(): void {
if (!activeViewAcceptedByDocument || !viewIdentityPersistent) return;
retirementRequired = true;
try {
window.sessionStorage.setItem(viewRetirementStorageKey, activeViewGeneration);
} catch {
forgetPersistedViewIdentity();
}
}

function acknowledgeViewRetirement(generation: string): void {
if (activeViewGeneration !== generation) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align the generation comparison with the canonicalization performed at load.

initialViewIdentity lowercases the restored generation, and the restored-retirement branch rewrites the marker in lowercase. This guard and the marker check at Line 1152 both use exact case-sensitive equality. The comparisons agree only because every producer now emits lowercase values. If any future producer stores a mixed-case generation, the acknowledgement is silently skipped and the marker is never cleared.

Consider comparing normalized values, or add a short comment that all generations are canonical lowercase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator-app/frontend/src/main.ts` at line 1146, Normalize generation values
before the activeViewGeneration guard and the restored-retirement marker
comparison, matching the lowercase canonicalization used by initialViewIdentity
and the marker rewrite. Preserve the existing acknowledgement and
marker-clearing behavior while ensuring mixed-case producer values compare
consistently.

acknowledgedRetirementGeneration = generation;
activeViewAcceptedByDocument = false;
retirementRequired = false;
if (!viewIdentityPersistent) return;
try {
if (window.sessionStorage.getItem(viewRetirementStorageKey) === generation) {
window.sessionStorage.removeItem(viewRetirementStorageKey);
}
} catch {
viewIdentityPersistent = false;
}
}
Comment on lines +1145 to +1158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear the persisted identity when the retirement marker cannot be removed.

Every other storage-failure handler in this change calls forgetPersistedViewIdentity(). This handler only sets viewIdentityPersistent = false and leaves the retirement marker in sessionStorage. The marker still matches the persisted generation, so initialViewIdentity takes the restored-retirement branch on the next load and returns retirementPending: true. The successor document then retires a generation that the backend already retired. retireBrowserView rejects that request, and the successor lands in the mandatory-reconnect state with no socket.

Use the same fail-closed helper here.

🛠️ Proposed fix
   if (!viewIdentityPersistent) return;
   try {
     if (window.sessionStorage.getItem(viewRetirementStorageKey) === generation) {
       window.sessionStorage.removeItem(viewRetirementStorageKey);
     }
   } catch {
-    viewIdentityPersistent = false;
+    forgetPersistedViewIdentity();
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@operator-app/frontend/src/main.ts` around lines 1145 - 1158, Update the catch
block in acknowledgeViewRetirement to call forgetPersistedViewIdentity() instead
of only setting viewIdentityPersistent to false, ensuring the matching
retirement marker and persisted identity are cleared on storage-removal failure.


async function connectState(retireCurrentView = false): Promise<void> {
if (postRetirementConflict) return;
if (connectionTransition) return connectionTransition;
const transition = connectStateOnce(retireCurrentView);
connectionTransition = transition;
Expand Down Expand Up @@ -1153,12 +1254,33 @@ function restoreStrandedRecovery(): void {
}

function reportDuplicateViewConflict(retirementAcknowledged: boolean): void {
armTasking.checked = false;
postRetirementConflict = retirementAcknowledged;
browserConnection = 'duplicate';
taskOutcome.textContent = retirementAcknowledged
? 'A successor view was refused after backend retirement was acknowledged. No further connection was attempted; reload before tasking.'
: 'This operator view identity remains active after bounded retries. Close the other tab or reload before reconnecting; tasking remains disabled.';
render();
if (postRetirementConflict) {
reconnectViewButton.disabled = true;
recoverViewButton.disabled = true;
}
}

function rotateContestedViewIdentity(): void {
activeViewId = window.crypto.randomUUID();
activeViewGeneration = window.crypto.randomUUID();
activeViewAcceptedByDocument = false;
retirementRequired = false;
acknowledgedRetirementGeneration = null;
postRetirementConflict = false;
if (!viewIdentityPersistent) return;
try {
window.sessionStorage.setItem(viewIdentityStorageKey, activeViewId);
window.sessionStorage.removeItem(viewGenerationStorageKey);
window.sessionStorage.removeItem(viewRetirementStorageKey);
} catch {
forgetPersistedViewIdentity();
}
}

async function connectStateOnce(retireCurrentView: boolean): Promise<void> {
Expand Down Expand Up @@ -1200,6 +1322,7 @@ async function connectStateOnce(retireCurrentView: boolean): Promise<void> {
render();
try {
await retireBrowserView(activeViewId, retiringGeneration);
acknowledgeViewRetirement(retiringGeneration);
} catch {
if (!pageActive) return;
Comment on lines 1323 to 1327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow recovery when persisted retirement proof is lost

If the backend restarts after pagehide stores a retirement marker but before the next page load, its in-memory _active_views and _retired_views no longer contain this generation, so /api/view/retire returns 409. This catch leaves retirementRequired set, returns before opening a successor socket, and every Reconnect click repeats the same failing retirement; the operator view remains permanently unusable until session storage is manually cleared. Handle the backend's “retirement is not proven” response by safely abandoning or rotating the persisted identity rather than retrying it forever.

Useful? React with 👍 / 👎.

const previous = socket;
Expand Down Expand Up @@ -1232,12 +1355,16 @@ async function connectStateOnce(retireCurrentView: boolean): Promise<void> {
);
browserConnection = 'connecting';
render();
const retryLimit = retireCurrentView ? 0 : duplicateViewRetryLimit;
const retirementAcknowledged =
retireCurrentView || acknowledgedRetirementGeneration !== null;
const retryLimit = retirementAcknowledged ? 0 : duplicateViewRetryLimit;
for (let duplicateRetries = 0; duplicateRetries <= retryLimit; duplicateRetries += 1) {
activeViewGeneration = window.crypto.randomUUID();
const candidateGeneration = window.crypto.randomUUID();
let outcome: StateSocketOutcome;
try {
outcome = await bindStateSocket(stateWebSocket(activeViewId, activeViewGeneration));
outcome = await bindStateSocket(stateWebSocket(activeViewId, candidateGeneration), {
onAccepted: () => acceptViewGeneration(candidateGeneration),
});
} catch {
browserConnection = 'disconnected';
render();
Expand All @@ -1255,7 +1382,7 @@ async function connectStateOnce(retireCurrentView: boolean): Promise<void> {
return;
}
if (duplicateRetries === retryLimit) {
reportDuplicateViewConflict(retireCurrentView);
reportDuplicateViewConflict(retirementAcknowledged);
return;
}
browserConnection = 'connecting';
Expand All @@ -1278,12 +1405,15 @@ async function recoverStrandedViewOnce(
return;
}
const retiringGeneration = activeViewGeneration;
try {
await retireBrowserView(retirementViewId, retiringGeneration);
} catch {
if (!pageActive) return;
restoreStrandedRecovery();
return;
if (acknowledgedRetirementGeneration !== retiringGeneration) {
try {
await retireBrowserView(retirementViewId, retiringGeneration);
acknowledgeViewRetirement(retiringGeneration);
} catch {
if (!pageActive) return;
restoreStrandedRecovery();
return;
}
}
if (!pageActive) return;
if (
Expand All @@ -1295,13 +1425,14 @@ async function recoverStrandedViewOnce(
restoreStrandedRecovery();
return;
}
activeViewGeneration = window.crypto.randomUUID();
const candidateGeneration = window.crypto.randomUUID();
try {
const candidate = stateWebSocket(activeViewId, activeViewGeneration);
const candidate = stateWebSocket(activeViewId, candidateGeneration);
recoverySocket = candidate;
const outcome = await bindStateSocket(candidate, {
preserveStrandedBeforeAcceptance: true,
onAccepted: () => {
acceptViewGeneration(candidateGeneration);
if (review === strandedReview) review = null;
if (
deferredStrandedPreparation?.viewId === retirementViewId &&
Expand All @@ -1323,10 +1454,14 @@ async function recoverStrandedViewOnce(
outcome.code === duplicateViewCloseCode &&
outcome.reason === duplicateViewCloseReason
) {
postRetirementConflict = true;
browserConnection = 'duplicate';
taskOutcome.textContent = strandedOutcomeMessage(
'A successor view was refused after backend retirement was acknowledged.',
'A successor view was refused after backend retirement was acknowledged. Reload before attempting another recovery.',
);
render();
reconnectViewButton.disabled = true;
recoverViewButton.disabled = true;
}
} catch {
recoverySocket = null;
Expand All @@ -1336,7 +1471,14 @@ async function recoverStrandedViewOnce(

function recoverStrandedView(): void {
const strandedReview = review;
if (!strandedReview || strandedReview.status !== 'stranded' || connectionTransition) return;
if (
!strandedReview ||
strandedReview.status !== 'stranded' ||
connectionTransition ||
postRetirementConflict
) {
return;
}
setReviewState('invalidating');
armTasking.checked = false;
browserConnection = 'connecting';
Expand Down Expand Up @@ -1545,7 +1687,15 @@ confirmDialog.addEventListener('cancel', (event) => {
void discardPreparationFromBrowser();
});
reconnectViewButton.addEventListener('click', () => {
void connectState(true);
if (
browserConnection === 'duplicate' &&
!activeViewAcceptedByDocument &&
!retirementRequired &&
!postRetirementConflict
) {
rotateContestedViewIdentity();
}
void connectState(activeViewAcceptedByDocument || retirementRequired);
});
recoverViewButton.addEventListener('click', () => {
recoverStrandedView();
Expand Down Expand Up @@ -1583,6 +1733,7 @@ window.addEventListener('pagehide', (event) => {
browserConnection = 'reconnect';
closeStateSocketForBrowserTransition();
abandonPreparationWithView(null);
preserveAcceptedViewForRetirement();
if (!event.persisted) {
basemap?.removeFrom(map);
basemap = null;
Expand All @@ -1594,8 +1745,7 @@ window.addEventListener('pageshow', (event) => {
pageActive = true;
viewGeneration += 1;
browserConnection = navigator.onLine ? 'reconnect' : 'offline';
render();
if (navigator.onLine) void connectState(true);
if (navigator.onLine) void connectState(activeViewAcceptedByDocument || retirementRequired);
});

setInterval(render, 1_000);
Expand All @@ -1606,8 +1756,7 @@ async function start(): Promise<void> {
modeLabel.textContent = `${configuration.mode.toUpperCase()} · ${configuration.integrations.join(', ')} · max ${configuration.maximum_entities}`;
configureBasemap(configuration);
configureFilters(configuration);
render();
void connectState();
void connectState(retirementRequired);
} catch (error) {
setConnection('startup failed', false);
const item = document.createElement('li');
Expand Down
Loading