Split client and server revision tracking

This commit is contained in:
Jage9
2026-03-09 02:31:00 -04:00
parent 0cf660c606
commit 1a8f750aa6
10 changed files with 105 additions and 64 deletions

View File

@@ -38,7 +38,8 @@
- Runtime/protocol behavior changes: update `docs/runtime-flow.md` and/or `docs/protocol-notes.md`. - Runtime/protocol behavior changes: update `docs/runtime-flow.md` and/or `docs/protocol-notes.md`.
## Versioning & Configuration ## 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. - 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. - Docs-only changes do not require a version bump unless explicitly requested.
- Do not duplicate version constants elsewhere in client code. - Do not duplicate version constants elsewhere in client code.

View File

@@ -1,6 +1,5 @@
// Maintainer-controlled web client version metadata. // Maintainer-controlled web client version metadata.
window.CHGRID_RELEASE_VERSION = "0.1.0"; window.CHGRID_RELEASE_VERSION = "0.1.1";
window.CHGRID_BUILD_REVISION = "R349"; window.CHGRID_CLIENT_REVISION = "R350";
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -92,8 +92,7 @@ const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy';
declare global { declare global {
interface Window { interface Window {
CHGRID_RELEASE_VERSION?: string; CHGRID_RELEASE_VERSION?: string;
CHGRID_BUILD_REVISION?: string; CHGRID_CLIENT_REVISION?: string;
CHGRID_WEB_VERSION?: string;
} }
} }
@@ -219,13 +218,10 @@ function announceMenuEntry(title: string, firstOption: string): void {
} }
const APP_RELEASE_VERSION = String(window.CHGRID_RELEASE_VERSION ?? '').trim(); const APP_RELEASE_VERSION = String(window.CHGRID_RELEASE_VERSION ?? '').trim();
const APP_BUILD_REVISION = String(window.CHGRID_BUILD_REVISION ?? '').trim(); const APP_CLIENT_REVISION = String(window.CHGRID_CLIENT_REVISION ?? '').trim();
const APP_VERSION = String( const APP_DISPLAY_VERSION = [APP_RELEASE_VERSION, APP_CLIENT_REVISION].filter((value) => value.length > 0).join(' ').trim();
window.CHGRID_WEB_VERSION ?? dom.appVersion.textContent = APP_DISPLAY_VERSION
[APP_RELEASE_VERSION, APP_BUILD_REVISION].filter((value) => value.length > 0).join(' ') ? `Another AI experiment with Jage. Version ${APP_DISPLAY_VERSION}`
).trim();
dom.appVersion.textContent = APP_VERSION
? `Another AI experiment with Jage. Version ${APP_VERSION}`
: 'Another AI experiment with Jage. Version unknown'; : 'Another AI experiment with Jage. Version unknown';
const DEFAULT_GRID_NAME = 'Chat Grid'; const DEFAULT_GRID_NAME = 'Chat Grid';
const DEFAULT_WELCOME_MESSAGE = 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. */ /** 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); 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())); nextUrl.searchParams.set('t', String(Date.now()));
window.location.replace(nextUrl.toString()); window.location.replace(nextUrl.toString());
} }
@@ -1385,6 +1381,18 @@ function sendAuthRequest(): void {
/** Handles server auth-required prompts prior to world welcome. */ /** Handles server auth-required prompts prior to world welcome. */
function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void { function handleAuthRequired(message: Extract<IncomingMessage, { type: 'auth_required' }>): void {
applyGridBranding(message.gridName, message.welcomeMessage); 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); authController.handleAuthRequired(message);
} }
@@ -1609,22 +1617,21 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
message.auth?.adminMenuActions; message.auth?.adminMenuActions;
authController.applyWelcomeAuth(message.auth, uiAdminActions); authController.applyWelcomeAuth(message.auth, uiAdminActions);
const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; 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 connectedAnnouncement = reconnectInFlight
? `Reconnected to server. Version ${incomingVersion}.` ? `Reconnected to server. Version ${incomingServerVersion}.`
: `Connected to server. Version ${incomingVersion}.`; : `Connected to server. Version ${incomingServerVersion}.`;
playSelfLoginSound = !reconnectInFlight; playSelfLoginSound = !reconnectInFlight;
if ( if (
!reloadScheduledForVersionMismatch && !reloadScheduledForVersionMismatch &&
APP_VERSION && expectedClientRevision &&
incomingVersion && expectedClientRevision !== APP_CLIENT_REVISION
incomingVersion !== 'unknown' &&
incomingVersion !== APP_VERSION
) { ) {
reloadScheduledForVersionMismatch = true; reloadScheduledForVersionMismatch = true;
pushChatMessage(`Server version ${incomingVersion} detected. Reloading client...`); pushChatMessage(`Server expects client ${expectedClientRevision}. Reloading client...`);
window.setTimeout(() => { window.setTimeout(() => {
reloadClientForVersion(incomingVersion); reloadClientForVersion(expectedClientRevision);
}, 50); }, 50);
return; return;
} }

View File

@@ -55,7 +55,9 @@ export const welcomeMessageSchema = z.object({
serverInfo: z serverInfo: z
.object({ .object({
instanceId: z.string(), instanceId: z.string(),
version: z.string().optional(), releaseVersion: z.string().optional(),
serverVersion: z.string().optional(),
expectedClientRevision: z.string().optional(),
gridName: z.string().optional(), gridName: z.string().optional(),
welcomeMessage: z.string().optional(), welcomeMessage: z.string().optional(),
}) })
@@ -140,6 +142,9 @@ export const authRequiredSchema = z.object({
message: z.string(), message: z.string(),
gridName: z.string().optional(), gridName: z.string().optional(),
welcomeMessage: z.string().optional(), welcomeMessage: z.string().optional(),
releaseVersion: z.string().optional(),
expectedClientRevision: z.string().optional(),
serverVersion: z.string().optional(),
authPolicy: z authPolicy: z
.object({ .object({
usernameMinLength: z.number().int().positive(), usernameMinLength: z.number().int().positive(),

View File

@@ -38,6 +38,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
## Server -> Client ## Server -> Client
- `auth_required`: authentication challenge after websocket connect. - `auth_required`: authentication challenge after websocket connect.
- includes `gridName`, `welcomeMessage`, `serverVersion`, and `expectedClientRevision`.
- `auth_result`: auth success/failure and session/account metadata. - `auth_result`: auth success/failure and session/account metadata.
- `auth_permissions`: server-pushed live role/permission refresh for current session. - `auth_permissions`: server-pushed live role/permission refresh for current session.
- `admin_roles_list`: role list response payload. - `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`) - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`)
- `auth_required.authPolicy`: server auth limits advertised before login/register submit. - `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.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.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 <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `X-Chgrid-Auth-Client: 1`) so the server can issue an instance-scoped `HttpOnly` session cookie. - `auth_result.sessionToken` is used by the client to call the instance-scoped HTTP endpoint `GET <base_path>auth/session/set` (`Authorization: Bearer <sessionToken>`, `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. - `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.player`: server-assigned spawn/current self position at connect time.
- `welcome.serverInfo`: server process identity/version metadata: - `welcome.serverInfo`: server process identity/version metadata:
- `instanceId`: unique id generated at server startup - `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 - `gridName`: server-owned user-facing grid name
- `welcomeMessage`: server-owned pre-login welcome string - `welcomeMessage`: server-owned pre-login welcome string
- `welcome.uiDefinitions`: server-provided item UI definitions: - `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.` - After reconnect, if `welcome.serverInfo.instanceId` changed, client announces `Server restarted.`
- Client emits `Connected to server. Version <version>.` on initial `welcome` and - Client emits `Connected to server. Version <version>.` on initial `welcome` and
`Reconnected to server. Version <version>.` after reconnect. `Reconnected to server. Version <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.

View File

@@ -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. 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`. 5. If resume does not authenticate, server sends `auth_required`.
- includes `gridName` and `welcomeMessage` for pre-login branding. - includes `gridName` and `welcomeMessage` for pre-login branding.
- includes `serverVersion` and `expectedClientRevision` for stale-client detection before login.
- includes `authPolicy` limits for username/password. - includes `authPolicy` limits for username/password.
6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller). 6. Client sends `auth_login` or `auth_register` (or explicit `auth_resume` if provided by caller).
7. Server sends `auth_result`. 7. Server sends `auth_result`.
@@ -20,8 +21,8 @@
- applies `welcome.worldConfig.movementTickMs` as movement pacing guidance - applies `welcome.worldConfig.movementTickMs` as movement pacing guidance
- applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity - applies `welcome.worldConfig.movementMaxStepsPerTick` for movement-rate parity
- uses `welcome.player` as authoritative starting position (restored from server-side account state when available) - 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 - records `welcome.serverInfo` (`instanceId`, `releaseVersion`, `serverVersion`, `expectedClientRevision`, `gridName`, `welcomeMessage`) for restart detection and client branding
- if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - 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 - 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_position` echo from server-assigned starting tile
- sends initial `update_nickname` - sends initial `update_nickname`
@@ -75,6 +76,7 @@ Core incoming message effects:
- Reconnect flow waits 5 seconds and retries up to 3 times. - Reconnect flow waits 5 seconds and retries up to 3 times.
- If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart. - 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. - 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 ## Authorization Runtime

View File

@@ -244,6 +244,9 @@ class AuthRequiredPacket(BasePacket):
authPolicy: dict | None = None authPolicy: dict | None = None
gridName: str | None = None gridName: str | None = None
welcomeMessage: str | None = None welcomeMessage: str | None = None
releaseVersion: str | None = None
expectedClientRevision: str | None = None
serverVersion: str | None = None
class AuthResultPacket(BasePacket): class AuthResultPacket(BasePacket):

View File

@@ -8,7 +8,6 @@ from collections import deque
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timezone from datetime import datetime, timezone
from getpass import getpass from getpass import getpass
from importlib.metadata import PackageNotFoundError, version as package_version
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -113,6 +112,7 @@ from .ui_metadata import (
ITEM_MANAGEMENT_ACTION_DEFINITIONS, ITEM_MANAGEMENT_ACTION_DEFINITIONS,
MAIN_MODE_SERVER_COMMAND_DEFINITIONS, MAIN_MODE_SERVER_COMMAND_DEFINITIONS,
) )
from .version import format_server_version
LOGGER = logging.getLogger("chgrid.server") LOGGER = logging.getLogger("chgrid.server")
PACKET_LOGGER = logging.getLogger("chgrid.server.packet") PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
@@ -196,7 +196,8 @@ class SignalingServer:
self.movement_tick_ms = MOVEMENT_TICK_MS self.movement_tick_ms = MOVEMENT_TICK_MS
self.movement_max_steps_per_tick = MOVEMENT_MAX_STEPS_PER_TICK self.movement_max_steps_per_tick = MOVEMENT_MAX_STEPS_PER_TICK
self.instance_id = str(uuid.uuid4()) 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.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.base_path = self._normalize_base_path(base_path)
self.grid_name = str(grid_name).strip() or "Chat Grid" 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 self._pending_reboot_task: asyncio.Task[None] | None = None
@staticmethod @staticmethod
def _resolve_server_version() -> str: def _resolve_server_version(release_version: str) -> str:
"""Resolve serverInfo version, preferring synced web version when available.""" """Resolve server diagnostics version text."""
env_override = os.getenv("CHGRID_SERVER_VERSION", "").strip() env_override = os.getenv("CHGRID_SERVER_VERSION", "").strip()
if env_override: if env_override:
return 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: try:
version_file = Path(__file__).resolve().parents[2] / "client" / "public" / "version.js" version_file = Path(__file__).resolve().parents[2] / "client" / "public" / "version.js"
text = version_file.read_text(encoding="utf-8") text = version_file.read_text(encoding="utf-8")
token = SignalingServer._version_from_web_version_text(text) return SignalingServer._client_version_metadata_from_web_version_text(text)
if token:
return token
except OSError: except OSError:
pass return "", ""
try:
return package_version("chgrid-server")
except PackageNotFoundError:
return "unknown"
@staticmethod @staticmethod
def _version_from_web_version_text(text: str) -> str: def _client_version_metadata_from_web_version_text(text: str) -> tuple[str, str]:
"""Parse release/build metadata from one client version.js file.""" """Parse release/client revision metadata from one client version.js file."""
release_match = re.search(r'CHGRID_RELEASE_VERSION\s*=\s*"([^"]+)"', text) release_match = re.search(r'CHGRID_RELEASE_VERSION\s*=\s*"([^"]+)"', text)
revision_match = re.search(r'CHGRID_BUILD_REVISION\s*=\s*"([^"]+)"', text) revision_match = re.search(r'CHGRID_CLIENT_REVISION\s*=\s*"([^"]+)"', text)
if release_match or revision_match: return (
parts = [ release_match.group(1).strip() if release_match else "",
release_match.group(1).strip() if release_match else "", revision_match.group(1).strip() if revision_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 ""
@property @property
def items(self) -> dict[str, WorldItem]: def items(self) -> dict[str, WorldItem]:
@@ -1522,6 +1514,9 @@ class SignalingServer:
authPolicy=self._auth_policy(), authPolicy=self._auth_policy(),
gridName=self.grid_name, gridName=self.grid_name,
welcomeMessage=self.welcome_message, 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: async for raw_message in websocket:
@@ -1580,7 +1575,9 @@ class SignalingServer:
uiDefinitions=self._build_ui_definitions(client), uiDefinitions=self._build_ui_definitions(client),
serverInfo={ serverInfo={
"instanceId": self.instance_id, "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, "gridName": self.grid_name,
"welcomeMessage": self.welcome_message, "welcomeMessage": self.welcome_message,
}, },

22
server/app/version.py Normal file
View File

@@ -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"

View File

@@ -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" 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 = """ version_text = """
window.CHGRID_RELEASE_VERSION = "0.1.0"; window.CHGRID_RELEASE_VERSION = "0.1.1";
window.CHGRID_BUILD_REVISION = "R348"; window.CHGRID_CLIENT_REVISION = "R350";
window.CHGRID_WEB_VERSION = `${window.CHGRID_RELEASE_VERSION} ${window.CHGRID_BUILD_REVISION}`;
""".strip() """.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 @pytest.mark.asyncio