From 1a8f750aa6a18e4710d1d9f12c0e6ee94d279990 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Mon, 9 Mar 2026 02:31:00 -0400 Subject: [PATCH] Split client and server revision tracking --- AGENTS.md | 3 +- client/public/version.js | 5 +- client/src/main.ts | 47 ++++++++++------- client/src/network/protocol.ts | 7 ++- docs/protocol-notes.md | 10 +++- docs/runtime-flow.md | 6 ++- server/app/models.py | 3 ++ server/app/server.py | 55 +++++++++----------- server/app/version.py | 22 ++++++++ server/tests/test_server_message_handling.py | 11 ++-- 10 files changed, 105 insertions(+), 64 deletions(-) create mode 100644 server/app/version.py diff --git a/AGENTS.md b/AGENTS.md index 07a85a9..0ef7d4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ - Runtime/protocol behavior changes: update `docs/runtime-flow.md` and/or `docs/protocol-notes.md`. ## Versioning & Configuration -- Bump `client/public/version.js` on every user-visible change using release version + revision metadata (`CHGRID_RELEASE_VERSION`, `CHGRID_BUILD_REVISION`, and derived `CHGRID_WEB_VERSION`, for example `0.1.0` + `R340`). +- Bump `client/public/version.js` on every user-visible client change using shared release version + client revision metadata (`CHGRID_RELEASE_VERSION` and `CHGRID_CLIENT_REVISION`, for example `0.1.1` + `R350`). +- Keep the server-only revision in `server/app/version.py`; server revisions do not require a client version bump unless browser code/assets changed. - Commit each completed logical change; include the version bump in that same commit when client behavior changes. - Docs-only changes do not require a version bump unless explicitly requested. - Do not duplicate version constants elsewhere in client code. diff --git a/client/public/version.js b/client/public/version.js index 0fc9288..8d5b09c 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,6 +1,5 @@ // Maintainer-controlled web client version metadata. -window.CHGRID_RELEASE_VERSION = "0.1.0"; -window.CHGRID_BUILD_REVISION = "R349"; -window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`; +window.CHGRID_RELEASE_VERSION = "0.1.1"; +window.CHGRID_CLIENT_REVISION = "R350"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 3dc14e3..4152885 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -92,8 +92,7 @@ const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy'; declare global { interface Window { CHGRID_RELEASE_VERSION?: string; - CHGRID_BUILD_REVISION?: string; - CHGRID_WEB_VERSION?: string; + CHGRID_CLIENT_REVISION?: string; } } @@ -219,13 +218,10 @@ function announceMenuEntry(title: string, firstOption: string): void { } const APP_RELEASE_VERSION = String(window.CHGRID_RELEASE_VERSION ?? '').trim(); -const APP_BUILD_REVISION = String(window.CHGRID_BUILD_REVISION ?? '').trim(); -const APP_VERSION = String( - window.CHGRID_WEB_VERSION ?? - [APP_RELEASE_VERSION, APP_BUILD_REVISION].filter((value) => value.length > 0).join(' ') -).trim(); -dom.appVersion.textContent = APP_VERSION - ? `Another AI experiment with Jage. Version ${APP_VERSION}` +const APP_CLIENT_REVISION = String(window.CHGRID_CLIENT_REVISION ?? '').trim(); +const APP_DISPLAY_VERSION = [APP_RELEASE_VERSION, APP_CLIENT_REVISION].filter((value) => value.length > 0).join(' ').trim(); +dom.appVersion.textContent = APP_DISPLAY_VERSION + ? `Another AI experiment with Jage. Version ${APP_DISPLAY_VERSION}` : 'Another AI experiment with Jage. Version unknown'; const DEFAULT_GRID_NAME = 'Chat Grid'; const DEFAULT_WELCOME_MESSAGE = @@ -803,9 +799,9 @@ function handleSignalingStatus(message: string): void { } /** Performs cache-busted navigation so the browser loads the newest client bundle. */ -function reloadClientForVersion(version: string): void { +function reloadClientForVersion(versionToken: string): void { const nextUrl = new URL(window.location.href); - nextUrl.searchParams.set('v', version || 'unknown'); + nextUrl.searchParams.set('v', versionToken || 'unknown'); nextUrl.searchParams.set('t', String(Date.now())); window.location.replace(nextUrl.toString()); } @@ -1385,6 +1381,18 @@ function sendAuthRequest(): void { /** Handles server auth-required prompts prior to world welcome. */ function handleAuthRequired(message: Extract): void { applyGridBranding(message.gridName, message.welcomeMessage); + const expectedClientRevision = String(message.expectedClientRevision ?? '').trim(); + if (!reloadScheduledForVersionMismatch && expectedClientRevision && expectedClientRevision !== APP_CLIENT_REVISION) { + reloadScheduledForVersionMismatch = true; + const serverVersion = String(message.serverVersion ?? '').trim() || 'unknown'; + pushChatMessage( + `Server ${serverVersion} expects client ${expectedClientRevision}. Reloading client...`, + ); + window.setTimeout(() => { + reloadClientForVersion(expectedClientRevision); + }, 50); + return; + } authController.handleAuthRequired(message); } @@ -1609,22 +1617,21 @@ async function onSignalingMessage(message: IncomingMessage): Promise { message.auth?.adminMenuActions; authController.applyWelcomeAuth(message.auth, uiAdminActions); const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; - const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; + const incomingServerVersion = String(message.serverInfo?.serverVersion ?? '').trim() || 'unknown'; + const expectedClientRevision = String(message.serverInfo?.expectedClientRevision ?? '').trim(); connectedAnnouncement = reconnectInFlight - ? `Reconnected to server. Version ${incomingVersion}.` - : `Connected to server. Version ${incomingVersion}.`; + ? `Reconnected to server. Version ${incomingServerVersion}.` + : `Connected to server. Version ${incomingServerVersion}.`; playSelfLoginSound = !reconnectInFlight; if ( !reloadScheduledForVersionMismatch && - APP_VERSION && - incomingVersion && - incomingVersion !== 'unknown' && - incomingVersion !== APP_VERSION + expectedClientRevision && + expectedClientRevision !== APP_CLIENT_REVISION ) { reloadScheduledForVersionMismatch = true; - pushChatMessage(`Server version ${incomingVersion} detected. Reloading client...`); + pushChatMessage(`Server expects client ${expectedClientRevision}. Reloading client...`); window.setTimeout(() => { - reloadClientForVersion(incomingVersion); + reloadClientForVersion(expectedClientRevision); }, 50); return; } diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 3ba229b..0341415 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -55,7 +55,9 @@ export const welcomeMessageSchema = z.object({ serverInfo: z .object({ instanceId: z.string(), - version: z.string().optional(), + releaseVersion: z.string().optional(), + serverVersion: z.string().optional(), + expectedClientRevision: z.string().optional(), gridName: z.string().optional(), welcomeMessage: z.string().optional(), }) @@ -140,6 +142,9 @@ export const authRequiredSchema = z.object({ message: z.string(), gridName: z.string().optional(), welcomeMessage: z.string().optional(), + releaseVersion: z.string().optional(), + expectedClientRevision: z.string().optional(), + serverVersion: z.string().optional(), authPolicy: z .object({ usernameMinLength: z.number().int().positive(), diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index d1d2dd6..82fd85b 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -38,6 +38,7 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Server -> Client - `auth_required`: authentication challenge after websocket connect. + - includes `gridName`, `welcomeMessage`, `serverVersion`, and `expectedClientRevision`. - `auth_result`: auth success/failure and session/account metadata. - `auth_permissions`: server-pushed live role/permission refresh for current session. - `admin_roles_list`: role list response payload. @@ -96,6 +97,8 @@ This is a behavior guide for packet semantics beyond raw schemas. - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`) - `auth_required.authPolicy`: server auth limits advertised before login/register submit. - `auth_required.gridName` / `auth_required.welcomeMessage`: server-owned pre-login branding values. +- `auth_required.serverVersion`: server diagnostics version text shown in connect/reconnect messaging. +- `auth_required.expectedClientRevision`: authoritative browser asset revision required by this server instance. - `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses. - `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET auth/session/set` (`Authorization: Bearer `, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie. - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. @@ -104,7 +107,9 @@ This is a behavior guide for packet semantics beyond raw schemas. - `welcome.player`: server-assigned spawn/current self position at connect time. - `welcome.serverInfo`: server process identity/version metadata: - `instanceId`: unique id generated at server startup - - `version`: server package version (or `unknown` fallback) + - `releaseVersion`: shared public release version + - `serverVersion`: server diagnostics version text (`release + server revision`) + - `expectedClientRevision`: browser asset revision required by this server instance - `gridName`: server-owned user-facing grid name - `welcomeMessage`: server-owned pre-login welcome string - `welcome.uiDefinitions`: server-provided item UI definitions: @@ -157,4 +162,5 @@ This is a behavior guide for packet semantics beyond raw schemas. - After reconnect, if `welcome.serverInfo.instanceId` changed, client announces `Server restarted.` - Client emits `Connected to server. Version .` on initial `welcome` and `Reconnected to server. Version .` after reconnect. -- If `welcome.serverInfo.version` differs from running client version, client auto-reloads. +- If `auth_required.expectedClientRevision` or `welcome.serverInfo.expectedClientRevision` differs from the running client revision, client auto-reloads. +- Server-only version changes do not trigger browser reload unless `expectedClientRevision` also changes. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 69f0424..8443df4 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -8,6 +8,7 @@ 4. Server accepts the socket only on the configured instance websocket path and when the browser `Origin` matches `CHGRID_HOST_ORIGIN`, then attempts cookie-based session resume from the instance-scoped websocket handshake cookie. 5. If resume does not authenticate, server sends `auth_required`. - includes `gridName` and `welcomeMessage` for pre-login branding. + - includes `serverVersion` and `expectedClientRevision` for stale-client detection before login. - includes `authPolicy` limits for username/password. 6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller). 7. Server sends `auth_result`. @@ -20,8 +21,8 @@ - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity - uses `welcome.player` as authoritative starting position (restored from server-side account state when available) - - records `welcome.serverInfo` (`instanceId`, `version`, `gridName`, `welcomeMessage`) for restart detection and client branding - - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page + - records `welcome.serverInfo` (`instanceId`, `releaseVersion`, `serverVersion`, `expectedClientRevision`, `gridName`, `welcomeMessage`) for restart detection and client branding + - if `welcome.serverInfo.expectedClientRevision` differs from the running client revision, auto-reloads the page - applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order - sends initial `update_position` echo from server-assigned starting tile - sends initial `update_nickname` @@ -75,6 +76,7 @@ Core incoming message effects: - Reconnect flow waits 5 seconds and retries up to 3 times. - If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart. - Connect/reconnect status message is emitted from `welcome` and includes server version. +- Server-only deploys no longer force browser reloads unless `expectedClientRevision` changes. ## Authorization Runtime diff --git a/server/app/models.py b/server/app/models.py index da97761..5a52641 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -244,6 +244,9 @@ class AuthRequiredPacket(BasePacket): authPolicy: dict | None = None gridName: str | None = None welcomeMessage: str | None = None + releaseVersion: str | None = None + expectedClientRevision: str | None = None + serverVersion: str | None = None class AuthResultPacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 59403f2..731d406 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -8,7 +8,6 @@ from collections import deque from contextlib import suppress from datetime import datetime, timezone from getpass import getpass -from importlib.metadata import PackageNotFoundError, version as package_version import ipaddress import json import logging @@ -113,6 +112,7 @@ from .ui_metadata import ( ITEM_MANAGEMENT_ACTION_DEFINITIONS, MAIN_MODE_SERVER_COMMAND_DEFINITIONS, ) +from .version import format_server_version LOGGER = logging.getLogger("chgrid.server") PACKET_LOGGER = logging.getLogger("chgrid.server.packet") @@ -196,7 +196,8 @@ class SignalingServer: self.movement_tick_ms = MOVEMENT_TICK_MS self.movement_max_steps_per_tick = MOVEMENT_MAX_STEPS_PER_TICK self.instance_id = str(uuid.uuid4()) - self.server_version = self._resolve_server_version() + self.release_version, self.expected_client_revision = self._resolve_client_version_metadata() + self.server_version = self._resolve_server_version(self.release_version) self.host_origin = normalize_origin(host_origin, field_name="host origin") if host_origin else None self.base_path = self._normalize_base_path(base_path) self.grid_name = str(grid_name).strip() or "Chat Grid" @@ -225,45 +226,36 @@ class SignalingServer: self._pending_reboot_task: asyncio.Task[None] | None = None @staticmethod - def _resolve_server_version() -> str: - """Resolve serverInfo version, preferring synced web version when available.""" + def _resolve_server_version(release_version: str) -> str: + """Resolve server diagnostics version text.""" env_override = os.getenv("CHGRID_SERVER_VERSION", "").strip() if env_override: return env_override + return format_server_version(release_version) + + @staticmethod + def _resolve_client_version_metadata() -> tuple[str, str]: + """Resolve shared release version and expected client revision from version.js.""" + try: version_file = Path(__file__).resolve().parents[2] / "client" / "public" / "version.js" text = version_file.read_text(encoding="utf-8") - token = SignalingServer._version_from_web_version_text(text) - if token: - return token + return SignalingServer._client_version_metadata_from_web_version_text(text) except OSError: - pass - - try: - return package_version("chgrid-server") - except PackageNotFoundError: - return "unknown" + return "", "" @staticmethod - def _version_from_web_version_text(text: str) -> str: - """Parse release/build metadata from one client version.js file.""" + def _client_version_metadata_from_web_version_text(text: str) -> tuple[str, str]: + """Parse release/client revision metadata from one client version.js file.""" release_match = re.search(r'CHGRID_RELEASE_VERSION\s*=\s*"([^"]+)"', text) - revision_match = re.search(r'CHGRID_BUILD_REVISION\s*=\s*"([^"]+)"', text) - if release_match or revision_match: - parts = [ - release_match.group(1).strip() if release_match else "", - revision_match.group(1).strip() if revision_match else "", - ] - token = " ".join(part for part in parts if part) - if token: - return token - legacy_match = re.search(r'CHGRID_WEB_VERSION\s*=\s*"([^"]+)"', text) - if legacy_match: - return legacy_match.group(1).strip() - return "" + revision_match = re.search(r'CHGRID_CLIENT_REVISION\s*=\s*"([^"]+)"', text) + return ( + release_match.group(1).strip() if release_match else "", + revision_match.group(1).strip() if revision_match else "", + ) @property def items(self) -> dict[str, WorldItem]: @@ -1522,6 +1514,9 @@ class SignalingServer: authPolicy=self._auth_policy(), gridName=self.grid_name, welcomeMessage=self.welcome_message, + releaseVersion=self.release_version or None, + expectedClientRevision=self.expected_client_revision or None, + serverVersion=self.server_version, ), ) async for raw_message in websocket: @@ -1580,7 +1575,9 @@ class SignalingServer: uiDefinitions=self._build_ui_definitions(client), serverInfo={ "instanceId": self.instance_id, - "version": self.server_version, + "releaseVersion": self.release_version, + "serverVersion": self.server_version, + "expectedClientRevision": self.expected_client_revision, "gridName": self.grid_name, "welcomeMessage": self.welcome_message, }, diff --git a/server/app/version.py b/server/app/version.py new file mode 100644 index 0000000..6e8891c --- /dev/null +++ b/server/app/version.py @@ -0,0 +1,22 @@ +"""Server version metadata helpers. + +This module owns the server-only revision identifier used for diagnostics and +`/version` output. The shared public release version continues to come from the +client's `public/version.js` metadata so one release number can be used across +the whole app. +""" + +from __future__ import annotations + + +SERVER_REVISION = "S001" + + +def format_server_version(release_version: str) -> str: + """Return display text for the current server build.""" + + release = str(release_version).strip() + revision = str(SERVER_REVISION).strip() + if release and revision: + return f"{release} {revision}" + return release or revision or "unknown" diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 7f054e6..69682ba 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -65,15 +65,14 @@ def test_client_ip_ignores_forwarded_for_from_non_loopback_peer() -> None: assert server._client_ip(client) == "203.0.113.20" -def test_resolve_server_version_reads_release_and_revision(monkeypatch: pytest.MonkeyPatch) -> None: +def test_resolve_client_version_metadata_reads_release_and_revision(monkeypatch: pytest.MonkeyPatch) -> None: version_text = """ -window.CHGRID_RELEASE_VERSION = "0.1.0"; -window.CHGRID_BUILD_REVISION = "R348"; -window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`; +window.CHGRID_RELEASE_VERSION = "0.1.1"; +window.CHGRID_CLIENT_REVISION = "R350"; """.strip() - resolved = SignalingServer._version_from_web_version_text(version_text) + resolved = SignalingServer._client_version_metadata_from_web_version_text(version_text) - assert resolved == "0.1.0 R348" + assert resolved == ("0.1.1", "R350") @pytest.mark.asyncio