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`.
## 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.

View File

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

View File

@@ -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<IncomingMessage, { type: 'auth_required' }>): 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<void> {
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;
}

View File

@@ -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(),

View File

@@ -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 <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.
@@ -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 <version>.` on initial `welcome` and
`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.
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

View File

@@ -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):

View File

@@ -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 = [
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 "",
]
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
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,
},

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