@mycadet/cli 0.12.9-dev.202610151822.dbac7cb → 0.12.9-dev.202610160107.b6eac7a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/cli",
3
- "version": "0.12.9-dev.202610151822.dbac7cb",
3
+ "version": "0.12.9-dev.202610160107.b6eac7a",
4
4
  "description": "CLI tools for mycadet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,6 +51,10 @@ mock.module("node:fs", () => ({
51
51
  readFileSync: readFileSyncMock,
52
52
  }));
53
53
 
54
+ const fetchCurrentVersionMock = mock<typeof httpClient.fetchCurrentVersion>(
55
+ async () => "1.2.3",
56
+ );
57
+
54
58
  const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
55
59
  async () => true,
56
60
  );
@@ -58,6 +62,7 @@ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
58
62
  mock.module("../lib/http-client.js", () => ({
59
63
  ...httpClient,
60
64
  waitForDaemonReady: waitForDaemonReadyMock,
65
+ fetchCurrentVersion: fetchCurrentVersionMock,
61
66
  }));
62
67
 
63
68
  // Restore the real modules once this file finishes so the mocks do not leak
@@ -72,7 +77,6 @@ import {
72
77
  buildIngressNginxConfig,
73
78
  hasIpv6Loopback,
74
79
  buildRemoteWebIndexHtml,
75
- cloudWebHubUrl,
76
80
  MAX_PRESERVED_MODULE_PRELOADS,
77
81
  ensureTunnelEdge,
78
82
  EDGE_TEMPLATE_VERSION,
@@ -124,6 +128,8 @@ afterEach(() => {
124
128
  existsSyncMock.mockImplementation(realFs.existsSync);
125
129
  readFileSyncMock.mockReset();
126
130
  readFileSyncMock.mockImplementation(realFs.readFileSync);
131
+ fetchCurrentVersionMock.mockReset();
132
+ fetchCurrentVersionMock.mockImplementation(async () => "1.2.3");
127
133
  waitForDaemonReadyMock.mockReset();
128
134
  waitForDaemonReadyMock.mockImplementation(async () => true);
129
135
  for (const dir of workspaces.splice(0)) {
@@ -524,18 +530,6 @@ describe("buildRemoteWebIndexHtml", () => {
524
530
  });
525
531
  });
526
532
 
527
- describe("cloudWebHubUrl", () => {
528
- test("maps build environments to their cloud SPA base", () => {
529
- expect(cloudWebHubUrl("production")).toBe("https://mycadet.ai/assistant");
530
- expect(cloudWebHubUrl("staging")).toBe(
531
- "https://staging.mycadet.ai/assistant",
532
- );
533
- expect(cloudWebHubUrl("dev")).toBe("https://dev.mycadet.ai/assistant");
534
- expect(cloudWebHubUrl("local")).toBe("https://dev.mycadet.ai/assistant");
535
- expect(cloudWebHubUrl(undefined)).toBe("https://dev.mycadet.ai/assistant");
536
- });
537
- });
538
-
539
533
  describe("nginx ingress process state", () => {
540
534
  function writeIngressState(workspaceDir: string, listenPort: number): void {
541
535
  writeFileSync(
@@ -743,17 +737,19 @@ function mockWebDistMissing(): void {
743
737
  }
744
738
 
745
739
  /** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
746
- function mockWebDistPresent(): void {
740
+ function mockWebDistPresent(version: string | null = "1.2.3"): void {
747
741
  existsSyncMock.mockImplementation((path) =>
748
742
  String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
749
743
  );
750
744
  readFileSyncMock.mockImplementation(((path, options) =>
751
745
  String(path).endsWith(WEB_INDEX_SUFFIX)
752
746
  ? FAKE_INDEX_HTML
753
- : realFs.readFileSync(
754
- path as never,
755
- options as never,
756
- )) as typeof readFileSync);
747
+ : String(path).endsWith("release.json")
748
+ ? JSON.stringify(version === null ? {} : { version })
749
+ : realFs.readFileSync(
750
+ path as never,
751
+ options as never,
752
+ )) as typeof readFileSync);
757
753
  }
758
754
 
759
755
  /** Record a running edge: ingress state, live pidfile, matching ps output. */
@@ -860,6 +856,7 @@ function spaConfigHash(
860
856
  hubUrl: PRODUCTION_HUB_URL,
861
857
  },
862
858
  webDistDir: opts.webDistDir ?? findWebDistDir(),
859
+ version: "1.2.3",
863
860
  indexHtml: opts.indexHtml ?? FAKE_INDEX_HTML,
864
861
  }),
865
862
  )
@@ -885,6 +882,70 @@ function pinProductionEnvironment(): void {
885
882
  describe("startRemoteWebIngress", () => {
886
883
  pinProductionEnvironment();
887
884
 
885
+ for (const [webVersion, runtimeVersion, ready] of [
886
+ ["1.2.3", "1.2.3", true],
887
+ ["1.2.3", "v1.2.3", true],
888
+ ["1.2.2", "1.2.3", false],
889
+ ["1.2.3", undefined, false],
890
+ [null, "1.2.3", false],
891
+ ] as const) {
892
+ test(`release resolution: UI ${webVersion}, assistant ${runtimeVersion}`, async () => {
893
+ const ws = makeWorkspace();
894
+ mockNginxInstalled();
895
+ mockNginxSpawn();
896
+ mockWebDistPresent(webVersion);
897
+ fetchCurrentVersionMock.mockResolvedValue(runtimeVersion);
898
+ const result = await startRemoteWebIngress({
899
+ workspaceDir: ws,
900
+ gatewayPort: 7830,
901
+ listenPort: 7845,
902
+ assistantId: "assistant-1",
903
+ });
904
+ expect(result.status).toBe("started");
905
+ expect(fetchCurrentVersionMock).toHaveBeenCalledWith(
906
+ "http://127.0.0.1:7830",
907
+ );
908
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
909
+ const detail = conf
910
+ .split('location = "/v1/assistants/assistant-1/" {')[1]
911
+ ?.split("\n }")[0];
912
+ expect(detail).toContain(`return ${ready ? 200 : 409} `);
913
+ if (ready) {
914
+ expect(detail).toContain('\\"version\\":\\"1.2.3\\"');
915
+ } else {
916
+ expect(detail).not.toContain('\\"url\\"');
917
+ }
918
+ });
919
+ }
920
+
921
+ test("restarts an edge when its verified assistant/UI release changes", async () => {
922
+ const ws = makeWorkspace();
923
+ mockNginxInstalled();
924
+ mockNginxSpawn();
925
+ mockWebDistPresent("1.2.3");
926
+ fetchCurrentVersionMock.mockResolvedValue("1.2.4");
927
+ const pid = mockRunningEdge(ws, {
928
+ listenPort: 7845,
929
+ includeWebApp: true,
930
+ gatewayPort: 7830,
931
+ remoteWebConfigHash: spaConfigHash({ assistantId: "assistant-1" }),
932
+ });
933
+ const edge = mockKillableNginx(pid);
934
+ const result = await startRemoteWebIngress({
935
+ workspaceDir: ws,
936
+ gatewayPort: 7830,
937
+ listenPort: 7845,
938
+ assistantId: "assistant-1",
939
+ });
940
+ expect(result.status).toBe("started");
941
+ expect(edge.killed()).toBe(true);
942
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf8");
943
+ const detail = conf
944
+ .split('location = "/v1/assistants/assistant-1/" {')[1]
945
+ ?.split("\n }")[0];
946
+ expect(detail).toContain("return 409 ");
947
+ });
948
+
888
949
  test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
889
950
  const ws = makeWorkspace();
890
951
  mockNginxInstalled();
@@ -2266,7 +2327,7 @@ describe("assistant metadata API", () => {
2266
2327
  listenPort: 7840,
2267
2328
  remoteWebIngress: {
2268
2329
  webDistDir: "/tmp/web",
2269
- uiVersion: "bundle-hash",
2330
+ version: "1.2.3",
2270
2331
  assistantId: id,
2271
2332
  assistantName: "My assistant",
2272
2333
  config: { appAllowedOrigins: ["https://parent.example"] },
@@ -2294,7 +2355,7 @@ describe("assistant metadata API", () => {
2294
2355
  id: "assistant-1",
2295
2356
  name: "My assistant",
2296
2357
  status: "active",
2297
- version: "bundle-hash",
2358
+ version: "1.2.3",
2298
2359
  url: "assistant/",
2299
2360
  });
2300
2361
  expect(conf).not.toContain('location = "/v1/assistants/other/"');
@@ -57,7 +57,6 @@ import {
57
57
  buildUpgradeCommitMessage,
58
58
  captureReplayState,
59
59
  captureUpgradeFailureLogs,
60
- fetchCurrentVersion,
61
60
  commitWorkspaceViaGateway,
62
61
  rollbackMigrations,
63
62
  UPGRADE_PROGRESS,
@@ -69,6 +68,7 @@ import {
69
68
  stripVersionPrefix,
70
69
  versionsEqual,
71
70
  } from "../lib/version-compat.js";
71
+ import { fetchCurrentVersion } from "../lib/http-client.js";
72
72
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
73
73
  import {
74
74
  generateLocalSigningKey,
@@ -0,0 +1,29 @@
1
+ import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
2
+ import { fetchCurrentVersion } from "../http-client.js";
3
+
4
+ const request = spyOn(globalThis, "fetch");
5
+ afterEach(() => request.mockReset());
6
+ afterAll(() => request.mockRestore());
7
+
8
+ test("reads the gateway's version through the public health endpoint", async () => {
9
+ request.mockResolvedValueOnce(
10
+ Response.json({ version: "1.2.3", status: "ok" }),
11
+ );
12
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBe("1.2.3");
13
+ expect(request.mock.calls[0][0]).toBe("http://127.0.0.1:7830/healthz");
14
+ });
15
+
16
+ test("an unverified gateway response never supplies a release version", async () => {
17
+ for (const value of [{}, null, { version: "" }, { version: 123 }]) {
18
+ request.mockResolvedValueOnce(Response.json(value));
19
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
20
+ }
21
+ request.mockResolvedValueOnce(new Response("not JSON"));
22
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
23
+ request.mockResolvedValueOnce(
24
+ Response.json({ version: "1.2.3" }, { status: 503 }),
25
+ );
26
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
27
+ request.mockRejectedValueOnce(new Error("offline"));
28
+ expect(await fetchCurrentVersion("http://127.0.0.1:7830")).toBeUndefined();
29
+ });
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- import { findWebDistDir } from "../web-dist.js";
6
+ import { findWebDistDir, readWebReleaseVersion } from "../web-dist.js";
7
7
 
8
8
  const tempDirs: string[] = [];
9
9
 
@@ -84,3 +84,29 @@ test("preserves installed package and source checkout resolution", () => {
84
84
  }),
85
85
  ).toBe(sourceDist);
86
86
  });
87
+
88
+ test("reads the built UI release, including a normalized version prefix", () => {
89
+ const root = makeTempDir();
90
+ writeFileSync(
91
+ join(root, "release.json"),
92
+ JSON.stringify({ version: "v1.2.3-dev.5" }),
93
+ );
94
+ expect(readWebReleaseVersion(root)).toBe("1.2.3-dev.5");
95
+ });
96
+
97
+ test("missing or malformed release metadata cannot stand in for a release", () => {
98
+ const root = makeTempDir();
99
+ expect(readWebReleaseVersion(root)).toBeUndefined();
100
+ for (const value of [
101
+ "not JSON",
102
+ "null",
103
+ "{}",
104
+ '{"version":null}',
105
+ '{"version":"latest"}',
106
+ '{"version":""}',
107
+ '{"version":"bundle-hash"}',
108
+ ]) {
109
+ writeFileSync(join(root, "release.json"), value);
110
+ expect(readWebReleaseVersion(root)).toBeUndefined();
111
+ }
112
+ });
@@ -28,7 +28,8 @@ import {
28
28
  readCloudToken,
29
29
  saveCloudToken,
30
30
  } from "./cloud-client.js";
31
- import { fetchAssistantIngressUrl, fetchCurrentVersion } from "./upgrade-lifecycle.js";
31
+ import { fetchAssistantIngressUrl } from "./upgrade-lifecycle.js";
32
+ import { fetchCurrentVersion } from "./http-client.js";
32
33
  import {
33
34
  CALLBACK_PATH,
34
35
  buildAuthorizeUrl,
@@ -27,7 +27,8 @@ import {
27
27
  } from "./root-backup.js";
28
28
  import { DOCKER_STATEFUL_SET_SPEC } from "./statefulset.js";
29
29
  import { execOutput } from "./step-runner.js";
30
- import { buildReplayState, fetchCurrentVersion } from "./upgrade-lifecycle.js";
30
+ import { buildReplayState } from "./upgrade-lifecycle.js";
31
+ import { fetchCurrentVersion } from "./http-client.js";
31
32
  import { compareVersions } from "./version-compat.js";
32
33
  import { sleepAssistant } from "../commands/sleep.js";
33
34
  import { generateInstanceName } from "./random-name.js";
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import { loopbackSafeFetch } from "./loopback-fetch.js";
2
3
 
3
4
  export type HttpProbeEndpoint = "healthz" | "readyz";
@@ -141,3 +142,27 @@ export async function waitForDaemonMigrationsReady(
141
142
  await new Promise((r) => setTimeout(r, 250));
142
143
  }
143
144
  }
145
+
146
+ const GatewayHealthSchema = z.object({ version: z.string().min(1).optional() });
147
+
148
+ /**
149
+ * Best-effort fetch of the running service group version from the gateway
150
+ * `/healthz` endpoint. Returns `undefined` when the endpoint is
151
+ * unreachable or does not include a version field.
152
+ */
153
+ export async function fetchCurrentVersion(
154
+ runtimeUrl: string,
155
+ ): Promise<string | undefined> {
156
+ try {
157
+ const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
158
+ signal: AbortSignal.timeout(5000),
159
+ });
160
+ if (resp.ok) {
161
+ const parsed = GatewayHealthSchema.safeParse(await resp.json());
162
+ return parsed.success ? parsed.data.version : undefined;
163
+ }
164
+ } catch {
165
+ // Best-effort
166
+ }
167
+ return undefined;
168
+ }
@@ -30,7 +30,7 @@ import {
30
30
  } from "./assistant-config.js";
31
31
  import type { RemoteHost } from "./constants.js";
32
32
  import { getCurrentEnvironment } from "./environments/resolve.js";
33
- import { waitForDaemonReady } from "./http-client.js";
33
+ import { fetchCurrentVersion, waitForDaemonReady } from "./http-client.js";
34
34
  import {
35
35
  getDefaultWorkspaceDir,
36
36
  isLocalContainerEntry,
@@ -38,7 +38,8 @@ import {
38
38
  parseGatewayPortFromEntryUrls,
39
39
  saveRawConfig,
40
40
  } from "./ingress-config.js";
41
- import { findWebDistDir } from "./web-dist.js";
41
+ import { stripVersionPrefix } from "./version-compat.js";
42
+ import { findWebDistDir, readWebReleaseVersion } from "./web-dist.js";
42
43
 
43
44
  export { findWebDistDir } from "./web-dist.js";
44
45
 
@@ -143,7 +144,8 @@ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
143
144
  export interface RemoteWebIngressOptions {
144
145
  webDistDir: string;
145
146
  indexHtmlPath?: string;
146
- uiVersion?: string;
147
+ /** Assistant release verified against the served UI artifact. */
148
+ version?: string;
147
149
  config?: Record<string, unknown>;
148
150
  /** Serving assistant's display name, stamped into the served config so
149
151
  * remote clients can label this origin. Absent in older served configs. */
@@ -153,19 +155,10 @@ export interface RemoteWebIngressOptions {
153
155
  * configs. */
154
156
  assistantId?: string;
155
157
  /** Cloud web SPA base the remote client can hand this origin to (see
156
- * `cloudWebHubUrl`). Absent in older served configs. */
158
+ * `cloudAssistantHubUrl`). Absent in older served configs. */
157
159
  hubUrl?: string;
158
160
  }
159
161
 
160
- /**
161
- * Cloud web SPA base URL for a build environment. The mapping lives in
162
- * `@mycadet/environments` (`cloudAssistantHubUrl`) and is shared with the
163
- * Capacitor shell's `server.url`, so the two consumers cannot drift.
164
- */
165
- export function cloudWebHubUrl(env: string | undefined): string {
166
- return cloudAssistantHubUrl(env);
167
- }
168
-
169
162
  // The edge serves remote browsers, so the bundle's remote-gateway machinery
170
163
  // must always run: it is reached through this self-hosted edge however the
171
164
  // cadet itself is hosted. "cloud" is never stamped (the cloud serves its
@@ -199,13 +192,6 @@ function remoteWebIngressConfig(
199
192
  opts.assistantId ? lockfileAssistantRemote(opts.assistantId) : undefined,
200
193
  ),
201
194
  apiBaseUrl: "/v1",
202
- ...(process.env.CADET_APP_ALLOWED_ORIGINS
203
- ? {
204
- appAllowedOrigins: process.env.CADET_APP_ALLOWED_ORIGINS.split(",")
205
- .map((origin) => origin.trim())
206
- .filter(Boolean),
207
- }
208
- : {}),
209
195
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
210
196
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
211
197
  ...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
@@ -218,7 +204,7 @@ function remoteWebIngressConfig(
218
204
  * fingerprint matches, so this must change whenever the generated index or
219
205
  * nginx template does.
220
206
  */
221
- export const EDGE_TEMPLATE_VERSION = 11;
207
+ export const EDGE_TEMPLATE_VERSION = 12;
222
208
 
223
209
  /**
224
210
  * Stable fingerprint of the injected SPA config, its source bundle, and
@@ -229,6 +215,7 @@ export const EDGE_TEMPLATE_VERSION = 11;
229
215
  function remoteWebConfigFingerprint(
230
216
  config: Record<string, unknown>,
231
217
  webDistDir: string,
218
+ version?: string,
232
219
  ): string {
233
220
  return createHash("sha256")
234
221
  .update(
@@ -236,6 +223,7 @@ function remoteWebConfigFingerprint(
236
223
  template: EDGE_TEMPLATE_VERSION,
237
224
  config,
238
225
  webDistDir,
226
+ version,
239
227
  indexHtml: readFileSync(join(webDistDir, "index.html"), "utf-8"),
240
228
  }),
241
229
  )
@@ -358,7 +346,7 @@ export async function findAvailableIngressPort(
358
346
  const AssistantHostConfigSchema = z.object({
359
347
  assistantId: z.string().min(1).optional(),
360
348
  assistantName: z.string().optional(),
361
- uiVersion: z.string().min(1).optional(),
349
+ version: z.string().min(1).optional(),
362
350
  hubUrl: z.string().url().optional(),
363
351
  appAllowedOrigins: z.array(z.string().url()).default([]),
364
352
  });
@@ -414,12 +402,12 @@ function assistantMetadataLocations(config: Record<string, unknown>): string {
414
402
  ? [
415
403
  {
416
404
  path: `/v1/assistants/${encodeURIComponent(id)}`,
417
- status: metadata.uiVersion ? 200 : 409,
418
- body: metadata.uiVersion
405
+ status: metadata.version ? 200 : 409,
406
+ body: metadata.version
419
407
  ? {
420
408
  ...choice,
421
409
  status: "active",
422
- version: metadata.uiVersion,
410
+ version: metadata.version,
423
411
  url: "assistant/",
424
412
  }
425
413
  : { detail: "assistant is not ready" },
@@ -473,7 +461,7 @@ export function buildIngressNginxConfig(opts: {
473
461
  webDistDir: remoteWebIngress.webDistDir,
474
462
  indexHtmlPath: remoteWebIngress.indexHtmlPath,
475
463
  config: remoteWebIngressConfig(remoteWebIngress),
476
- uiVersion: remoteWebIngress.uiVersion,
464
+ version: remoteWebIngress.version,
477
465
  })
478
466
  : `${DENYLIST_LOCATIONS}
479
467
 
@@ -567,7 +555,7 @@ function buildRemoteWebIngressLocations(opts: {
567
555
  webDistDir: string;
568
556
  indexHtmlPath?: string;
569
557
  config: Record<string, unknown>;
570
- uiVersion?: string;
558
+ version?: string;
571
559
  }): string {
572
560
  const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
573
561
  const webDistDir = nginxDirPath(opts.webDistDir);
@@ -578,7 +566,7 @@ function buildRemoteWebIngressLocations(opts: {
578
566
 
579
567
  return `${DENYLIST_LOCATIONS}
580
568
 
581
- ${assistantMetadataLocations({ ...opts.config, uiVersion: opts.uiVersion })}
569
+ ${assistantMetadataLocations({ ...opts.config, version: opts.version })}
582
570
 
583
571
  location = /healthz {
584
572
  ${proxyBlock}
@@ -837,9 +825,6 @@ export function startIngressNginx(opts: {
837
825
  join(remoteWebIngress.webDistDir, "index.html"),
838
826
  "utf-8",
839
827
  );
840
- remoteWebIngress.uiVersion = createHash("sha256")
841
- .update(rawIndexHtml)
842
- .digest("hex");
843
828
  writeFileSync(
844
829
  remoteWebIngress.indexHtmlPath,
845
830
  buildRemoteWebIndexHtml(rawIndexHtml, remoteWebIngress.config),
@@ -872,6 +857,7 @@ export function startIngressNginx(opts: {
872
857
  remoteWebConfigHash: remoteWebConfigFingerprint(
873
858
  remoteWebIngress.config,
874
859
  remoteWebIngress.webDistDir,
860
+ remoteWebIngress.version,
875
861
  ),
876
862
  }
877
863
  : {}),
@@ -1063,7 +1049,7 @@ export async function startRemoteWebIngress(opts: {
1063
1049
  // reuse decision and the actual spawn share one value and cannot drift.
1064
1050
  const spaOptions = includeWebApp
1065
1051
  ? {
1066
- hubUrl: cloudWebHubUrl(getCurrentEnvironment().name),
1052
+ hubUrl: cloudAssistantHubUrl(getCurrentEnvironment().name),
1067
1053
  ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
1068
1054
  ...(opts.assistantId ? { assistantId: opts.assistantId } : {}),
1069
1055
  }
@@ -1072,11 +1058,23 @@ export async function startRemoteWebIngress(opts: {
1072
1058
  if (includeWebApp && !webDistDir) {
1073
1059
  return { status: "web-dist-missing" };
1074
1060
  }
1061
+ const runtimeVersion = webDistDir
1062
+ ? await fetchCurrentVersion(`http://127.0.0.1:${opts.gatewayPort}`)
1063
+ : undefined;
1064
+ const webVersion = webDistDir ? readWebReleaseVersion(webDistDir) : undefined;
1065
+ const assistantVersion = runtimeVersion
1066
+ ? stripVersionPrefix(runtimeVersion)
1067
+ : undefined;
1068
+ const matchedVersion =
1069
+ assistantVersion && webVersion === assistantVersion
1070
+ ? assistantVersion
1071
+ : undefined;
1075
1072
  const requestedConfigHash =
1076
1073
  spaOptions && webDistDir
1077
1074
  ? remoteWebConfigFingerprint(
1078
1075
  remoteWebIngressConfig(spaOptions),
1079
1076
  webDistDir,
1077
+ matchedVersion,
1080
1078
  )
1081
1079
  : undefined;
1082
1080
  // Both sides are undefined in webhooks-only mode; an SPA record without a
@@ -1140,7 +1138,9 @@ export async function startRemoteWebIngress(opts: {
1140
1138
  workspaceDir: opts.workspaceDir,
1141
1139
  gatewayPort: opts.gatewayPort,
1142
1140
  listenPort,
1143
- remoteWebIngress: webDistDir ? { webDistDir, ...spaOptions } : undefined,
1141
+ remoteWebIngress: webDistDir
1142
+ ? { webDistDir, ...spaOptions, version: matchedVersion }
1143
+ : undefined,
1144
1144
  });
1145
1145
  let exited = false;
1146
1146
  child.once("exit", () => {
@@ -30,10 +30,6 @@ import { classifyReadyzResponse } from "./http-client.js";
30
30
  import { resolveImageRefs } from "./cloud-releases.js";
31
31
  import { z } from "zod";
32
32
 
33
- const GatewayHealthSchema = z.object({
34
- version: z.string().optional(),
35
- });
36
-
37
33
  const MigrationGateBodySchema = z.object({
38
34
  status: z.string().optional(),
39
35
  reason: z.string().optional(),
@@ -291,28 +287,6 @@ export async function captureReplayState(
291
287
  return buildReplayState(capturedEnv, gatewayEnv);
292
288
  }
293
289
 
294
- /**
295
- * Best-effort fetch of the running service group version from the gateway
296
- * `/healthz` endpoint. Returns `undefined` when the endpoint is
297
- * unreachable or does not include a version field.
298
- */
299
- export async function fetchCurrentVersion(
300
- runtimeUrl: string,
301
- ): Promise<string | undefined> {
302
- try {
303
- const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
304
- signal: AbortSignal.timeout(5000),
305
- });
306
- if (resp.ok) {
307
- const parsed = GatewayHealthSchema.safeParse(await resp.json());
308
- return parsed.success ? parsed.data.version : undefined;
309
- }
310
- } catch {
311
- // Best-effort
312
- }
313
- return undefined;
314
- }
315
-
316
290
  /**
317
291
  * Best-effort fetch of the assistant's configured public ingress URL from the
318
292
  * gateway `integrations/ingress/config` endpoint. Returns `undefined` when
@@ -1,4 +1,7 @@
1
- import { existsSync } from "node:fs";
1
+ import { stripVersionPrefix } from "./version-compat.js";
2
+ import { z } from "zod";
3
+ import { isValidReleaseVersion } from "@mycadet/local-mode";
4
+ import { existsSync, readFileSync } from "node:fs";
2
5
  import { createRequire } from "node:module";
3
6
  import { dirname, join } from "node:path";
4
7
 
@@ -46,3 +49,22 @@ export function findWebDistDir(options: WebDistOptions = {}): string | null {
46
49
  }
47
50
  return null;
48
51
  }
52
+
53
+ const WebReleaseSchema = z.object({
54
+ version: z
55
+ .string()
56
+ .min(1)
57
+ .refine((value) => value !== "latest" && isValidReleaseVersion(value)),
58
+ });
59
+
60
+ export function readWebReleaseVersion(distDir: string): string | undefined {
61
+ try {
62
+ const payload: unknown = JSON.parse(
63
+ readFileSync(join(distDir, "release.json"), "utf8"),
64
+ );
65
+ const parsed = WebReleaseSchema.safeParse(payload);
66
+ return parsed.success ? stripVersionPrefix(parsed.data.version) : undefined;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }