diff --git a/copilotj/server/protocol.py b/copilotj/server/protocol.py new file mode 100644 index 00000000..ac19caac --- /dev/null +++ b/copilotj/server/protocol.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright contributors to the CopilotJ project. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Frontend <-> server protocol versioning (issue #68). + +The protocol version uses ``MAJOR.MINOR`` semantics, declared as two separate +integers (so the bump intent is explicit) and concatenated into the exposed +``API_VERSION`` string: + +- bump ``API_VERSION_MAJOR`` on any breaking protocol change (route shapes, + NDJSON event contracts, required field removal/rename, etc.); +- bump ``API_VERSION_MINOR`` on backward-compatible additions (new optional + fields/endpoints). + +The combined ``API_VERSION`` string MUST be kept in sync with +``web/src/apis/version.ts`` ``API_VERSION``. + +Examples: + >>> API_VERSION == f"{API_VERSION_MAJOR}.{API_VERSION_MINOR}" + True + >>> API_VERSION + '1.0' +""" + +__all__ = ["API_VERSION", "API_VERSION_MAJOR", "API_VERSION_MINOR"] + +API_VERSION_MAJOR = 1 +API_VERSION_MINOR = 0 +API_VERSION = f"{API_VERSION_MAJOR}.{API_VERSION_MINOR}" diff --git a/copilotj/server/server.py b/copilotj/server/server.py index a4a68785..4102c9eb 100644 --- a/copilotj/server/server.py +++ b/copilotj/server/server.py @@ -13,6 +13,7 @@ from copilotj.core.kb import ensure_faiss_index_async from copilotj.core.lifecycle import run_cleanup from copilotj.server.bridge import Bridge +from copilotj.server.protocol import API_VERSION from copilotj.server.threads import Threads __all__ = ["Server"] @@ -78,6 +79,7 @@ def _create_app(self) -> web.Application: r = app.router r.add_get("/api/ping", _on_ping) + r.add_get("/api/version", _on_version) r.add_get("/api/config", self._on_config) r.add_get("/api/model/capabilities", self._on_model_capabilities) r.add_get("/api/models", self._on_list_models) @@ -253,5 +255,14 @@ async def _on_list_models(self, request: web.Request) -> web.Response: return web.json_response({"providers": {r["provider"]: r for r in results}}) +async def _on_version(request: web.Request) -> web.Response: + """Report the frontend/server protocol version (issue #68). + + The frontend compares this MAJOR against its baked-in ``API_VERSION`` and + warns the user when they differ (self-hosted server too old/new). + """ + return web.json_response({"api_version": API_VERSION}) + + async def _on_ping(request: web.Request) -> web.Response: return web.Response(text="pong") diff --git a/web/src/apis/index.ts b/web/src/apis/index.ts index 72d8e064..d40e4abc 100644 --- a/web/src/apis/index.ts +++ b/web/src/apis/index.ts @@ -6,3 +6,4 @@ export * from "./threads"; export * from "./models"; +export * from "./version"; diff --git a/web/src/apis/version.ts b/web/src/apis/version.ts new file mode 100644 index 00000000..f8303e7a --- /dev/null +++ b/web/src/apis/version.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: Copyright contributors to the CopilotJ project. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getBaseUrl } from "./base"; + +/** + * Protocol version this frontend was built against. + * + * MUST mirror the backend `copilotj/server/protocol.py` `API_VERSION`. Only the + * MAJOR component decides compatibility (see {@link parseApiMajor}). + */ +export const API_VERSION = "1.0"; + +export interface ServerVersion { + api_version: string; +} + +/** Fetch the server's protocol version via `GET /api/version`. */ +export async function getServerVersion(): Promise { + const url = `${getBaseUrl()}/version`; + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); +} + +/** Parse the MAJOR component of a "MAJOR.MINOR" version string. */ +export function parseApiMajor(version: string | null | undefined): number | null { + if (!version) return null; + const match = /^(\d+)/.exec(version.trim()); + return match ? Number(match[1]) : null; +} + +/** Compatible iff the server's MAJOR matches this frontend's MAJOR. */ +export function isApiVersionCompatible(serverVersion: string | null | undefined): boolean { + const server = parseApiMajor(serverVersion); + const client = parseApiMajor(API_VERSION); + return server !== null && client !== null && server === client; +} diff --git a/web/src/components/ChatboxHello.vue b/web/src/components/ChatboxHello.vue index de8851b3..f7c3fb32 100644 --- a/web/src/components/ChatboxHello.vue +++ b/web/src/components/ChatboxHello.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: Apache-2.0 import { computed } from "vue"; import Logo from "./Logo.vue"; import { isApiBaseConfigurable } from "../apis/base"; +import { API_VERSION } from "../apis"; import { useSettings, useSystemState } from "../store"; const settings = useSettings(); @@ -17,6 +18,13 @@ const showConnectionWarning = computed( () => isApiBaseConfigurable && state.backendReachable === false && !state.connectionWarningDismissed, ); +const showVersionWarning = computed( + () => + isApiBaseConfigurable && + (state.apiVersionStatus === "incompatible" || state.apiVersionStatus === "unknown") && + !state.apiVersionWarningDismissed, +); + const suggestions = [ "Find best segmentation method for this image", "Segment this low signal-to-noise cell image", @@ -77,6 +85,40 @@ function usePromptSuggestion(suggestion: string) { +
+ + Your server (API v{{ state.serverApiVersion ?? "?" }}) is not compatible with this version of CopilotJ (expects + v{{ API_VERSION }}). Please update your CopilotJ server, or switch to a compatible one in + + . + + + Couldn't verify your server's API version — it may be outdated. If chat isn't working, please update your + CopilotJ server or check the URL in + + . + + +
+
import { ref, watch } from "vue"; import { setApiBaseUrl, testApiConnection } from "../apis/base"; -import { getServerConfig } from "../apis"; +import { getServerConfig, API_VERSION } from "../apis"; import type { ServerConfig } from "../apis"; +import { useSystemState } from "../store"; const props = defineProps<{ apiBaseUrl: string; @@ -21,6 +22,8 @@ const emit = defineEmits<{ (e: "update:serverConfig", value: ServerConfig | null): void; }>(); +const state = useSystemState(); + const apiBaseUrl = ref(props.apiBaseUrl); // Sync from prop (fixes pre-fill when parent sets value async) @@ -44,6 +47,8 @@ async function testConnection() { } catch { // Server may not return config } + // Verify frontend <-> server protocol version (issue #68). + await state.checkApiVersion(); } } @@ -70,6 +75,27 @@ async function testConnection() {

Could not reach the server. Please check the URL and try again.

+

+ Server API version (v{{ state.serverApiVersion ?? "?" }}) is not compatible with this frontend (expects v{{ + API_VERSION + }}). Please update your CopilotJ server. +

+

+ Couldn't verify the server's API version — it may be outdated. Consider updating your CopilotJ server if chat + doesn't work. +

+

+ Server API v{{ state.serverApiVersion }} +

diff --git a/web/src/store/state.ts b/web/src/store/state.ts index afed48fb..43c5da3f 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -7,6 +7,7 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { ref } from "vue"; import { getBaseUrl, testApiConnection } from "../apis/base"; +import { getServerVersion, isApiVersionCompatible } from "../apis/version"; export const useSystemState = defineStore("state", () => { const showSettings = ref(false); @@ -16,11 +17,28 @@ export const useSystemState = defineStore("state", () => { const backendReachable = ref(null); const connectionWarningDismissed = ref(false); + // Frontend <-> server protocol version check (issue #68) + const serverApiVersion = ref(null); + const apiVersionStatus = ref<"unknown" | "compatible" | "incompatible" | null>(null); + const apiVersionWarningDismissed = ref(false); + async function testBackendConnection() { const rawUrl = getBaseUrl().replace(/\/api$/, ""); backendReachable.value = await testApiConnection(rawUrl); } + async function checkApiVersion() { + try { + const { api_version } = await getServerVersion(); + serverApiVersion.value = api_version; + apiVersionStatus.value = isApiVersionCompatible(api_version) ? "compatible" : "incompatible"; + } catch { + // Endpoint missing (old server), network error, or unparseable → unknown. + serverApiVersion.value = null; + apiVersionStatus.value = "unknown"; + } + } + return { showManageAgents, showSettings, @@ -28,6 +46,10 @@ export const useSystemState = defineStore("state", () => { backendReachable, connectionWarningDismissed, testBackendConnection, + serverApiVersion, + apiVersionStatus, + apiVersionWarningDismissed, + checkApiVersion, }; }); diff --git a/web/src/views/Chat.vue b/web/src/views/Chat.vue index 972b8a2f..d277d909 100644 --- a/web/src/views/Chat.vue +++ b/web/src/views/Chat.vue @@ -27,6 +27,7 @@ onMounted(async () => { } state.testBackendConnection(); + state.checkApiVersion(); // 1. If config store has a user-configured model, apply it if (settings.model === null && config.data.defaultModel) { @@ -81,6 +82,7 @@ const settingsRef = ref | null>(null); function onSetupComplete() { state.wizardMode = false; state.testBackendConnection(); + state.checkApiVersion(); } function startNewThread() {