@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 +1 -1
- package/src/__tests__/nginx-ingress.test.ts +81 -20
- package/src/commands/upgrade.ts +1 -1
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/cloud-login.ts +2 -1
- package/src/lib/host-teleport.ts +2 -1
- package/src/lib/http-client.ts +25 -0
- package/src/lib/nginx-ingress.ts +33 -33
- package/src/lib/upgrade-lifecycle.ts +0 -26
- package/src/lib/web-dist.ts +23 -1
package/package.json
CHANGED
|
@@ -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
|
-
:
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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: "
|
|
2358
|
+
version: "1.2.3",
|
|
2298
2359
|
url: "assistant/",
|
|
2299
2360
|
});
|
|
2300
2361
|
expect(conf).not.toContain('location = "/v1/assistants/other/"');
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/lib/cloud-login.ts
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
readCloudToken,
|
|
29
29
|
saveCloudToken,
|
|
30
30
|
} from "./cloud-client.js";
|
|
31
|
-
import { fetchAssistantIngressUrl
|
|
31
|
+
import { fetchAssistantIngressUrl } from "./upgrade-lifecycle.js";
|
|
32
|
+
import { fetchCurrentVersion } from "./http-client.js";
|
|
32
33
|
import {
|
|
33
34
|
CALLBACK_PATH,
|
|
34
35
|
buildAuthorizeUrl,
|
package/src/lib/host-teleport.ts
CHANGED
|
@@ -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
|
|
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";
|
package/src/lib/http-client.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
* `
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
418
|
-
body: metadata.
|
|
405
|
+
status: metadata.version ? 200 : 409,
|
|
406
|
+
body: metadata.version
|
|
419
407
|
? {
|
|
420
408
|
...choice,
|
|
421
409
|
status: "active",
|
|
422
|
-
version: metadata.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|
package/src/lib/web-dist.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|