mirror of
https://github.com/cline/cline.git
synced 2026-09-16 21:01:52 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bdeeff68 | ||
|
|
49897830bb | ||
|
|
1f316a2734 | ||
|
|
9c1f9133c7 | ||
|
|
2faef2b40d | ||
|
|
64829bca8c | ||
|
|
4c9ba6b091 | ||
|
|
6138bdfe40 |
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
|
||||
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
|
||||
|
||||
## [3.89.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
|
||||
- Handle the DeepSeek V4 reasoning format.
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
@@ -24,6 +25,15 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
@@ -52,6 +62,7 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
@@ -76,6 +87,15 @@ describe("runDoctorCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
|
||||
mockResolveProductionHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
@@ -110,7 +130,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/apps/cli/src/index.ts"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/apps/cli/src/index.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
@@ -261,7 +282,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/src-tauri/bin/code-sidecar"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/src-tauri/bin/code-sidecar"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
@@ -54,6 +55,7 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
staleHubPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
@@ -77,7 +79,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
|
||||
// "--" stops pgrep's option parsing so patterns that start with dashes
|
||||
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
|
||||
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
return [];
|
||||
}
|
||||
@@ -148,6 +154,25 @@ function listStaleCliPids(): number[] {
|
||||
.map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleHubPids(currentHubPids: number[]): number[] {
|
||||
const current = new Set(currentHubPids.filter((pid) => pid > 0));
|
||||
const patterns = [
|
||||
"/sdk/packages/core/src/hub/daemon/entry.ts",
|
||||
"/sdk/packages/core/dist/hub/daemon/entry.js",
|
||||
"--cline-hub-daemon",
|
||||
];
|
||||
const records = new Map<number, ProcessRecord>();
|
||||
for (const pattern of patterns) {
|
||||
for (const record of listMatchingProcesses(pattern)) {
|
||||
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
|
||||
continue;
|
||||
}
|
||||
records.set(record.pid, record);
|
||||
}
|
||||
}
|
||||
return [...records.values()].map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleSidecarPids(): number[] {
|
||||
const patterns = [
|
||||
"/apps/examples/desktop-app/sidecar/index.ts",
|
||||
@@ -235,7 +260,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
|
||||
}
|
||||
|
||||
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
|
||||
if (!existsSync(ownerPath)) {
|
||||
return [];
|
||||
@@ -259,7 +284,7 @@ async function clearHubStartupArtifacts(
|
||||
_cwd: string,
|
||||
options?: { clearDiscovery?: boolean },
|
||||
): Promise<{ startupLocks: number; discovery: number }> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const startupLocks = listHubStartupLocks(_cwd);
|
||||
let clearedStartupLocks = 0;
|
||||
for (const artifact of startupLocks) {
|
||||
@@ -291,14 +316,25 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: undefined;
|
||||
const current = health ?? discovery;
|
||||
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
const listeningPids = listListeningPids(current?.port);
|
||||
const currentHubPids = [
|
||||
...(current?.pid ? [current.pid] : []),
|
||||
...listeningPids,
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
hubUrl: current?.url,
|
||||
@@ -306,7 +342,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
hubPid: current?.pid,
|
||||
hubStartedAt: health?.startedAt,
|
||||
hubUptime,
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
listeningPids,
|
||||
staleHubPids: listStaleHubPids(currentHubPids),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
@@ -388,6 +425,7 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(formatPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
@@ -412,6 +450,7 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleHubPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
@@ -423,7 +462,9 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully().catch(() => false)
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
@@ -431,13 +472,20 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
const staleHubTargets = before.staleHubPids.filter(
|
||||
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
);
|
||||
const killedStaleHubs = killPids(staleHubTargets);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const staleSidecarTargets = before.staleSidecarPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid) &&
|
||||
!staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedSidecars = killPids(staleSidecarTargets);
|
||||
@@ -459,6 +507,7 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
@@ -471,6 +520,7 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
|
||||
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
@@ -487,6 +537,7 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -12,6 +13,10 @@ const {
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
@@ -24,13 +29,25 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
|
||||
describe("createHubCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("includes uptime in hub status output", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(
|
||||
new Date("2026-01-01T00:01:05.000Z").getTime(),
|
||||
@@ -73,4 +90,37 @@ describe("createHubCommand", () => {
|
||||
uptime: "1m 5s",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -15,9 +16,9 @@ interface HubCommandIo {
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully()) {
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
}
|
||||
@@ -46,6 +47,12 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -112,10 +119,12 @@ export function createHubCommand(
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
})
|
||||
: undefined;
|
||||
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
io.writeln(
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
@@ -32,6 +36,21 @@ function createTempFile(pathSuffix: string): string {
|
||||
describe("getInstallationInfo", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -96,6 +115,21 @@ describe("getInstallationInfo", () => {
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -153,6 +187,39 @@ describe("auto update settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub restart owner selection", () => {
|
||||
afterEach(() => {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared hub owner outside production builds", () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
|
||||
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(owner.discoveryPath).not.toBe(
|
||||
"/tmp/cline-update-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
@@ -269,13 +271,22 @@ export function getPreferredKanbanInstaller(
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const check = await probeHubServer(url).catch(() => undefined);
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
@@ -288,20 +299,22 @@ async function waitForHubToStop(
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url).catch(() => undefined)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!health?.url) return;
|
||||
if (!discovery || !health?.url) return;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully().catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
@@ -310,14 +323,14 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, 3_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, 2_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Llms,
|
||||
ProviderSettingsManager,
|
||||
stopLocalHubServerGracefully,
|
||||
toHubHealthUrl,
|
||||
toHubStatusUrl,
|
||||
} from "@cline/core";
|
||||
import type { HubUINotifyPayload, SessionRecord } from "@cline/shared";
|
||||
|
||||
@@ -460,7 +460,11 @@ async function main(): Promise<void> {
|
||||
|
||||
const syncHealthState = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(toHubHealthUrl(hubUrl));
|
||||
const response = await fetch(toHubStatusUrl(hubUrl), {
|
||||
headers: hubAuthToken
|
||||
? { authorization: `Bearer ${hubAuthToken}` }
|
||||
: undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -701,7 +701,9 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
|
||||
if (this.hubUrl) {
|
||||
const healthy = await probeHubServer(this.hubUrl);
|
||||
const healthy = await probeHubServer(this.hubUrl, {
|
||||
authToken: this.hubAuthToken,
|
||||
});
|
||||
if (healthy?.url) {
|
||||
return {
|
||||
url: rememberRecoverableLocalHubUrl(healthy.url, this.hubAuthToken),
|
||||
@@ -733,7 +735,9 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
): Promise<HubResolution | undefined> {
|
||||
const discovery = await readHubDiscovery(discoveryPath);
|
||||
if (!discovery?.url) return undefined;
|
||||
const healthy = await probeHubServer(discovery.url);
|
||||
const healthy = await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
});
|
||||
return healthy?.url
|
||||
? {
|
||||
url: rememberRecoverableLocalHubUrl(healthy.url, discovery.authToken),
|
||||
|
||||
Generated
+16
-34
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
@@ -156,42 +156,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
|
||||
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
|
||||
"version": "0.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
|
||||
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
|
||||
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
|
||||
"version": "0.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
|
||||
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.35 <1",
|
||||
"@anthropic-ai/sdk": ">=0.50.3 <1",
|
||||
"google-auth-library": "^9.4.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -486,8 +486,8 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
|
||||
@@ -11,7 +11,7 @@ import axios from "axios"
|
||||
import JSON5 from "json5"
|
||||
import OpenAI from "openai"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineStorageMessage, getBase64ImageSource } from "@/shared/messages/content"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
@@ -302,10 +302,11 @@ namespace Gemini {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
const { mediaType, data } = getBase64ImageSource(block.source)
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
mimeType: mediaType,
|
||||
data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/sh
|
||||
* @returns Array of Anthropic-compatible messages with cache control applied
|
||||
*/
|
||||
export function sanitizeAnthropicMessages(
|
||||
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
|
||||
clineMessages: ClineStorageMessage[],
|
||||
supportCache: boolean,
|
||||
): Array<Anthropic.MessageParam> {
|
||||
// The latest message will be the new user message, one before will be the assistant message from a previous request,
|
||||
|
||||
@@ -60,7 +60,7 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
|
||||
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
|
||||
}
|
||||
|
||||
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
|
||||
export function convertAnthropicMessageToGemini(message: ClineStorageMessage): Content {
|
||||
return {
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: convertAnthropicContentToGemini(message.content),
|
||||
@@ -113,6 +113,7 @@ export function convertGeminiResponseToAnthropic(response: GenerateContentRespon
|
||||
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AssistantMessage } from "@mistralai/mistralai/models/components/assista
|
||||
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
|
||||
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
|
||||
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
|
||||
import { getImageDataUrl } from "@/shared/messages/content"
|
||||
|
||||
export type MistralMessage =
|
||||
| (SystemMessage & { role: "system" })
|
||||
@@ -33,7 +34,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
return {
|
||||
type: "image_url",
|
||||
imageUrl: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,7 @@ export function convertO1ResponseToAnthropicMessage(
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content"
|
||||
|
||||
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
|
||||
@@ -46,7 +47,7 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
|
||||
toolResultImages.push(getImageDataUrl(part.source))
|
||||
return "(see following user message for image)"
|
||||
}
|
||||
return part.text
|
||||
@@ -67,7 +68,7 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
|
||||
content: nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return `data:${part.source.media_type};base64,${part.source.data}`
|
||||
return getImageDataUrl(part.source)
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
ClineAssistantThinkingBlock,
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineImageContentBlock,
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
@@ -65,7 +65,7 @@ function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider)
|
||||
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
|
||||
*/
|
||||
export function convertToOpenAiMessages(
|
||||
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
provider?: ApiProvider,
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
@@ -144,7 +144,7 @@ export function convertToOpenAiMessages(
|
||||
role: "user",
|
||||
content: toolResultImages.map((part) => ({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function convertToOpenAiMessages(
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -421,6 +421,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineStorageMessage, getBase64ImageSource, getImageDataUrl } from "@/shared/messages/content"
|
||||
|
||||
/**
|
||||
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
|
||||
@@ -177,7 +177,7 @@ export function convertToOpenAIResponsesInput(
|
||||
const imageItem: any = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
|
||||
content: [{ type: "output_text", text: `[image:${getBase64ImageSource(part.source).mediaType}]` }],
|
||||
}
|
||||
// Set message-level id if available (though images typically don't have call_id)
|
||||
if (part.call_id) {
|
||||
@@ -218,7 +218,7 @@ export function convertToOpenAIResponsesInput(
|
||||
messageContent.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
image_url: getImageDataUrl(part.source),
|
||||
})
|
||||
break
|
||||
case "tool_result": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage, getImageDataUrl } from "@/shared/messages/content"
|
||||
|
||||
/**
|
||||
* DeepSeek Reasoner message format with reasoning_content support.
|
||||
@@ -87,7 +87,7 @@ export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]):
|
||||
hasImages = true
|
||||
imageParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export function convertToVsCodeLmMessages(
|
||||
: (toolMessage.content?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
@@ -87,7 +87,7 @@ export function convertToVsCodeLmMessages(
|
||||
...nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
}
|
||||
return new vscode.LanguageModelTextPart(part.text)
|
||||
@@ -199,6 +199,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +192,13 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
|
||||
let contextRawPath: string | undefined
|
||||
|
||||
try {
|
||||
// Get current active context (respects previous compactions)
|
||||
// Get current active context (respects previous compactions).
|
||||
// getTruncatedMessages types its output as Anthropic.MessageParam[], but it slices the Cline-stored
|
||||
// conversation history (ClineStorageMessage[]) passed in, so narrow it back here.
|
||||
const currentContext = params.contextManager.getTruncatedMessages(
|
||||
params.apiConversationHistory,
|
||||
params.conversationHistoryDeletedRange,
|
||||
)
|
||||
) as ClineStorageMessage[]
|
||||
|
||||
// Write context files for hook access
|
||||
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { McpMarketplaceCatalog } from "@/shared/mcp"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { syncWorker } from "@/shared/services/worker/sync"
|
||||
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory"
|
||||
@@ -233,7 +234,7 @@ export async function getMcpSettingsFilePath(settingsDirectoryPath: string): Pro
|
||||
return mcpSettingsFilePath
|
||||
}
|
||||
|
||||
export async function getSavedApiConversationHistory(taskId: string): Promise<Anthropic.MessageParam[]> {
|
||||
export async function getSavedApiConversationHistory(taskId: string): Promise<ClineStorageMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
|
||||
@@ -2018,7 +2018,10 @@ export class Task {
|
||||
}
|
||||
|
||||
// Response API requires native tool calls to be enabled
|
||||
const stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory, tools)
|
||||
// ContextManager types its truncated output as Anthropic.MessageParam[], but the history it slices is the
|
||||
// Cline-stored conversation history (ClineStorageMessage[]), so narrow it back for the provider boundary.
|
||||
const truncatedConversationHistory = contextManagementMetadata.truncatedConversationHistory as ClineStorageMessage[]
|
||||
const stream = this.api.createMessage(systemPrompt, truncatedConversationHistory, tools)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function filterMessagesForClaudeCode(messages: Anthropic.Messages.Message
|
||||
if (block.type === "image") {
|
||||
// Replace image blocks with text placeholders
|
||||
const sourceType = block.source?.type || "unknown"
|
||||
const mediaType = block.source?.media_type || "unknown"
|
||||
const mediaType = (block.source?.type === "base64" && block.source.media_type) || "unknown"
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
|
||||
|
||||
@@ -131,6 +131,27 @@ export function convertClineStorageToAnthropicMessage(
|
||||
return { role, content: cleanedContent }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cline stores images as base64, so an image block's source is always a base64 source.
|
||||
* The Anthropic SDK types the source as a Base64ImageSource | URLImageSource union, so this
|
||||
* narrows to the base64 variant for the transform layer. URL sources are not produced by Cline,
|
||||
* so they degrade to empty values rather than throwing.
|
||||
*/
|
||||
export function getBase64ImageSource(source: Anthropic.ImageBlockParam["source"]): { mediaType: string; data: string } {
|
||||
if (source.type === "base64") {
|
||||
return { mediaType: source.media_type, data: source.data }
|
||||
}
|
||||
return { mediaType: "", data: "" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a base64 data URL from an image block's source. See getBase64ImageSource.
|
||||
*/
|
||||
export function getImageDataUrl(source: Anthropic.ImageBlockParam["source"]): string {
|
||||
const { mediaType, data } = getBase64ImageSource(source)
|
||||
return `data:${mediaType};base64,${data}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a content block by removing Cline-specific fields and returning only Anthropic-compatible fields
|
||||
*/
|
||||
|
||||
@@ -46,6 +46,35 @@ describe("resolveHubUrl", () => {
|
||||
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
|
||||
it("uses the shared discovery owner in development builds", async () => {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-connect-test-data";
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
const readHubDiscovery = vi
|
||||
.spyOn(await import("../discovery"), "readHubDiscovery")
|
||||
.mockResolvedValue({
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v1",
|
||||
authToken: "test-token",
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25466/hub");
|
||||
|
||||
const discoveryPath = readHubDiscovery.mock.calls[0]?.[0].replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
expect(discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(discoveryPath).not.toBe(
|
||||
"/tmp/cline-connect-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default endpoint when no discovery file exists", async () => {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = "/tmp/missing-hub-discovery.json";
|
||||
vi.spyOn(
|
||||
|
||||
@@ -3,12 +3,16 @@ import type {
|
||||
HubReplyEnvelope,
|
||||
HubTransportFrame,
|
||||
} from "@cline/shared";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { createHubServerUrl, readHubDiscovery } from "../discovery";
|
||||
import {
|
||||
type HubEndpointOverrides,
|
||||
resolveHubEndpointOptions,
|
||||
} from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
|
||||
export interface HubConnection {
|
||||
send(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope>;
|
||||
@@ -68,13 +72,19 @@ function sameHubEndpoint(left: string, right: string): boolean {
|
||||
return leftUrl.toString() === rightUrl.toString();
|
||||
}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function resolveHubUrlAuthToken(url: URL): Promise<string | undefined> {
|
||||
const queryToken = url.searchParams.get("authToken")?.trim();
|
||||
url.searchParams.delete("authToken");
|
||||
if (queryToken) {
|
||||
return queryToken;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url && sameHubEndpoint(url.toString(), discovery.url)) {
|
||||
return discovery.authToken;
|
||||
@@ -87,7 +97,7 @@ export async function resolveHubUrl(
|
||||
): Promise<string> {
|
||||
const endpoint = resolveHubEndpointOptions(overrides);
|
||||
if (!hasExplicitEndpoint(overrides)) {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url) {
|
||||
return discovery.url;
|
||||
|
||||
@@ -546,6 +546,10 @@ describe("NodeHubClient", () => {
|
||||
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery-recovery.json",
|
||||
@@ -697,6 +701,10 @@ describe("NodeHubClient", () => {
|
||||
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery-explicit.json",
|
||||
@@ -764,6 +772,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
it("does not clear discovery on transient probe failure", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -798,9 +810,13 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery on build mismatch", async () => {
|
||||
it("keeps discovery on build mismatch when protocol is compatible", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -840,15 +856,19 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
|
||||
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
|
||||
"/tmp/hub-discovery.json",
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
|
||||
"ws://127.0.0.1:59999/hub",
|
||||
);
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery when a hub omits build metadata", async () => {
|
||||
it("keeps discovery when a hub omits build metadata but has compatible protocol", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -886,6 +906,57 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
|
||||
"ws://127.0.0.1:59999/hub",
|
||||
);
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery on protocol mismatch", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
readHubDiscovery: vi.fn(async () => ({
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v0",
|
||||
buildId: "old-build",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
clearHubDiscovery: vi.fn(async (...args: unknown[]) => {
|
||||
clearHubDiscoveryMock(...args);
|
||||
}),
|
||||
probeHubServer: vi.fn(async () => ({
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v0",
|
||||
buildId: "old-build",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
|
||||
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
|
||||
"/tmp/hub-discovery.json",
|
||||
@@ -914,6 +985,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -950,6 +1025,73 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
).toBeLessThan(readHubDiscoveryMock.mock.invocationCallOrder[1]);
|
||||
});
|
||||
|
||||
it("waits on shared discovery after spawning in development builds", async () => {
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
const spawnDetachedHubServerWithRetryMock = vi.fn(async () => undefined);
|
||||
const record = {
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v1",
|
||||
buildId: "test-build",
|
||||
authToken: "token",
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const readHubDiscoveryMock = vi.fn(async (path: string) =>
|
||||
path === "/tmp/shared-hub-discovery.json" ? record : undefined,
|
||||
);
|
||||
vi.doMock("../daemon", () => ({
|
||||
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/production-hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/shared-hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
readHubDiscovery: readHubDiscoveryMock,
|
||||
probeHubServer: vi.fn(async () => record),
|
||||
clearHubDiscovery: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const { ensureCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(
|
||||
ensureCompatibleLocalHubUrl({
|
||||
workspaceRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
}),
|
||||
).resolves.toBe("ws://127.0.0.1:25466/hub");
|
||||
expect(readHubDiscoveryMock).toHaveBeenCalledWith(
|
||||
"/tmp/shared-hub-discovery.json",
|
||||
);
|
||||
expect(readHubDiscoveryMock).not.toHaveBeenCalledWith(
|
||||
"/tmp/production-hub-discovery.json",
|
||||
);
|
||||
} finally {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restart explicit local endpoints after startup timeout", async () => {
|
||||
const readHubDiscoveryMock = vi.fn(async () => ({
|
||||
hubId: "hub-test",
|
||||
@@ -963,6 +1105,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type HubEventEnvelope,
|
||||
type HubReplyEnvelope,
|
||||
type HubTransportFrame,
|
||||
isHubProtocolCompatible,
|
||||
resolveClineBuildEnv,
|
||||
resolveHubCommandTimeoutMs,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
@@ -17,9 +19,11 @@ import {
|
||||
type HubOwnerContext,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveHubBuildId,
|
||||
} from "../discovery";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
|
||||
type PendingReply = {
|
||||
resolve: (reply: HubReplyEnvelope) => void;
|
||||
@@ -31,6 +35,12 @@ type SubscriptionEntry = {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
function resolveDefaultHubOwnerContext(): HubOwnerContext {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
type WebSocketLike = {
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
@@ -821,7 +831,7 @@ type HubProbeResult =
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
status: "unreachable" | "build_mismatch";
|
||||
status: "unreachable" | "protocol_mismatch";
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -835,18 +845,18 @@ async function probeCompatibleHubUrl(
|
||||
},
|
||||
): Promise<HubProbeResult> {
|
||||
const normalized = normalizeHubWebSocketUrl(url);
|
||||
const record = await probeHubServer(normalized);
|
||||
const record = await probeHubServer(normalized, {
|
||||
authToken: options?.authToken,
|
||||
});
|
||||
if (!record) {
|
||||
return {
|
||||
status: "unreachable",
|
||||
url: normalized,
|
||||
};
|
||||
}
|
||||
const buildId = resolveHubBuildId();
|
||||
const recordBuildId = record.buildId?.trim();
|
||||
if (!recordBuildId || recordBuildId !== buildId) {
|
||||
if (!isHubProtocolCompatible(record).compatible) {
|
||||
return {
|
||||
status: "build_mismatch",
|
||||
status: "protocol_mismatch",
|
||||
url: normalized,
|
||||
};
|
||||
}
|
||||
@@ -973,16 +983,18 @@ export async function resolveCompatibleLocalHubUrl(
|
||||
return compatible.status === "compatible" ? compatible.url : undefined;
|
||||
}
|
||||
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!record?.url) {
|
||||
return undefined;
|
||||
}
|
||||
const compatible = await probeCompatibleHubUrl(record.url);
|
||||
const compatible = await probeCompatibleHubUrl(record.url, {
|
||||
authToken: record.authToken,
|
||||
});
|
||||
if (compatible.status === "compatible") {
|
||||
return rememberRecoverableLocalHubUrl(compatible.url, record.authToken);
|
||||
}
|
||||
if (compatible.status === "build_mismatch") {
|
||||
if (compatible.status === "protocol_mismatch") {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
return undefined;
|
||||
@@ -1004,7 +1016,7 @@ export async function ensureCompatibleLocalHubUrl(
|
||||
if (options.endpoint?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
await spawnDetachedHubServerWithRetry(options.workspaceRoot ?? process.cwd());
|
||||
return await waitForCompatibleHubUrl(owner);
|
||||
}
|
||||
@@ -1032,8 +1044,9 @@ export async function requestHubShutdown(
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function stopLocalHubServerGracefully(): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
export async function stopLocalHubServerGracefully(
|
||||
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
|
||||
): Promise<boolean> {
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
@@ -1060,7 +1073,7 @@ export async function restartLocalHubIfIdleAfterStartupTimeout(options: {
|
||||
if (!isRecoverableLocalHubUrl(options.url)) {
|
||||
return undefined;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url || !sameNormalizedHubUrl(discovery.url, options.url)) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockCreateLocalHubScheduleRuntimeHandlers,
|
||||
mockInitVcr,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStartHubWebSocketServer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
})),
|
||||
mockInitVcr: vi.fn(),
|
||||
mockResolveHubEndpointOptions: vi.fn(
|
||||
(options: { host?: string; port?: number; pathname?: string }) => ({
|
||||
host: options.host ?? "127.0.0.1",
|
||||
port: options.port ?? 25463,
|
||||
pathname: options.pathname ?? "/hub",
|
||||
}),
|
||||
),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
})),
|
||||
mockStartHubWebSocketServer: vi.fn(async () => ({
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/shared", () => ({
|
||||
initVcr: mockInitVcr,
|
||||
resolveClineBuildEnv: () => "production",
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/runtime-handlers", () => ({
|
||||
createLocalHubScheduleRuntimeHandlers:
|
||||
mockCreateLocalHubScheduleRuntimeHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/defaults", () => ({
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
vi.mock("../server", () => ({
|
||||
startHubWebSocketServer: mockStartHubWebSocketServer,
|
||||
}));
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
describe("hub daemon entry", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
process.chdir(originalCwd);
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
mockCreateLocalHubScheduleRuntimeHandlers.mockClear();
|
||||
mockInitVcr.mockClear();
|
||||
mockResolveHubEndpointOptions.mockClear();
|
||||
mockResolveProductionHubOwnerContext.mockClear();
|
||||
mockResolveSharedHubOwnerContext.mockClear();
|
||||
mockStartHubWebSocketServer.mockClear();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the daemon with cron options for the daemon workspace root", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = [
|
||||
"node",
|
||||
"entry.js",
|
||||
"--cwd",
|
||||
cwd,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"30000",
|
||||
"--pathname",
|
||||
"/hub",
|
||||
];
|
||||
vi.spyOn(process, "on").mockImplementation(() => process);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(mockStartHubWebSocketServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockStartHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: "127.0.0.1",
|
||||
port: 30000,
|
||||
pathname: "/hub",
|
||||
owner: expect.objectContaining({ ownerId: "production" }),
|
||||
cronOptions: { workspaceRoot: cwd },
|
||||
}),
|
||||
);
|
||||
expect(mockCreateLocalHubScheduleRuntimeHandlers).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AgentRuntimeAbortError } from "@cline/agents";
|
||||
import { initVcr } from "@cline/shared";
|
||||
import { initVcr, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import { startHubWebSocketServer } from "../server";
|
||||
|
||||
initVcr(process.env.CLINE_VCR);
|
||||
@@ -62,7 +65,10 @@ async function main(): Promise<void> {
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
pathname: endpoint.pathname,
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
owner:
|
||||
resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext(),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
cronOptions: { workspaceRoot: options.cwd },
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
openSync,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
verifyHubConnection,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
createHubServerUrl,
|
||||
clearHubDiscovery,
|
||||
@@ -24,6 +25,9 @@ const {
|
||||
openSync: vi.fn(() => 17),
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
verifyHubConnection: vi.fn(),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
resolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
@@ -57,6 +61,9 @@ vi.mock("@cline/shared", () => ({
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
CLINE_HUB_PORT: 25463,
|
||||
CLINE_HUB_DEV_PORT: 25466,
|
||||
isHubProtocolCompatible: (record: { protocolVersion?: string }) => ({
|
||||
compatible: record.protocolVersion === "v1",
|
||||
}),
|
||||
isHubDaemonProcess: (env: NodeJS.ProcessEnv = process.env) =>
|
||||
env[CLINE_RUN_AS_HUB_DAEMON_ENV] === "1",
|
||||
resolveClineBuildEnv: () => "production",
|
||||
@@ -70,6 +77,7 @@ vi.mock("../client", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
@@ -88,6 +96,21 @@ describe("ensureDetachedHubServer", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
spawn.mockReset();
|
||||
spawn.mockImplementation(() => ({ unref: vi.fn() }));
|
||||
closeSync.mockReset();
|
||||
mkdirSync.mockReset();
|
||||
openSync.mockReset();
|
||||
openSync.mockImplementation(() => 17);
|
||||
rememberRecoverableLocalHubUrl.mockReset();
|
||||
rememberRecoverableLocalHubUrl.mockImplementation((url: string) => url);
|
||||
verifyHubConnection.mockReset();
|
||||
clearHubDiscovery.mockReset();
|
||||
clearHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockReset();
|
||||
requestHubShutdown.mockReset();
|
||||
requestHubShutdown.mockResolvedValue(true);
|
||||
readHubDiscovery.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
@@ -101,20 +124,16 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the daemon bind port 0 when the configured endpoint is occupied", async () => {
|
||||
it("does not use port 0 for default production startup", async () => {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
@@ -129,12 +148,13 @@ describe("ensureDetachedHubServer", () => {
|
||||
| undefined;
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(spawnArgs).toContain("--port");
|
||||
expect(spawnArgs).toContain("0");
|
||||
expect(spawnArgs).toContain("25463");
|
||||
expect(spawnArgs).not.toContain("0");
|
||||
expect(spawnOptions?.env?.[CLINE_RUN_AS_HUB_DAEMON_ENV]).toBe("1");
|
||||
});
|
||||
|
||||
@@ -153,11 +173,12 @@ describe("ensureDetachedHubServer", () => {
|
||||
})
|
||||
.mockImplementationOnce(() => ({ unref: vi.fn() }));
|
||||
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
@@ -168,7 +189,7 @@ describe("ensureDetachedHubServer", () => {
|
||||
const result = await pending;
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
@@ -247,7 +268,42 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse a healthy hub from a different build", async () => {
|
||||
it("prewarms on a fallback port when an empty-token hub cannot be retired", async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "",
|
||||
pid: 12345,
|
||||
});
|
||||
probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
});
|
||||
|
||||
const { prewarmDetachedHubServer } = await import(".");
|
||||
prewarmDetachedHubServer("/workspace", { allowPortFallback: true });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const spawnArgs = ((spawn as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls[0]?.[1] ?? []) as string[];
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(spawnArgs).toContain("--port");
|
||||
expect(spawnArgs).toContain("0");
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a protocol-compatible healthy hub from a different build", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery
|
||||
@@ -255,54 +311,215 @@ describe("ensureDetachedHubServer", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
});
|
||||
.mockResolvedValueOnce(undefined);
|
||||
probeHubServer.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
authToken: "new-token",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"old-token",
|
||||
);
|
||||
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
|
||||
expect(clearHubDiscovery.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
probeHubServer.mock.invocationCallOrder[2],
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
expect(clearHubDiscovery).not.toHaveBeenCalled();
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(verifyHubConnection).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse a healthy hub without build metadata", async () => {
|
||||
it("retires an existing hub with an empty discovery auth token before starting a replacement", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a targeted error when an incompatible hub cannot be retired", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v2",
|
||||
buildId: "future-build",
|
||||
});
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const pending = expect(
|
||||
ensureDetachedHubServer("/workspace"),
|
||||
).rejects.toThrow(
|
||||
"An incompatible Cline Hub is already running at ws://127.0.0.1:25463/hub and could not be retired automatically.",
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await pending;
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("retires a legacy shared production hub before resolving the production hub", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
resolveSharedHubOwnerContext.mockReturnValueOnce({
|
||||
discoveryPath: "/tmp/legacy-hub-discovery.json",
|
||||
});
|
||||
readHubDiscovery
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:39121/hub",
|
||||
authToken: "legacy-token",
|
||||
pid: 222,
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:39121/hub",
|
||||
"legacy-token",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(222, "SIGTERM");
|
||||
expect(clearHubDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/legacy-hub-discovery.json",
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when a compatible expected hub has no discovery record", async () => {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
await expect(ensureDetachedHubServer("/workspace")).rejects.toThrow(
|
||||
"A compatible Cline Hub is already running at ws://127.0.0.1:25463/hub, but its discovery record is missing or unreadable.",
|
||||
);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses matching discovery pid and token when retiring an incompatible expected-url hub", async () => {
|
||||
const kill = vi
|
||||
.spyOn(process, "kill")
|
||||
.mockImplementation((_pid, signal) => {
|
||||
if (signal === 0) {
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
readHubDiscovery
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v2",
|
||||
buildId: "future-build",
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"old-token",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse a healthy hub without protocol metadata", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery
|
||||
@@ -311,7 +528,8 @@ describe("ensureDetachedHubServer", () => {
|
||||
authToken: "old-token",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
@@ -327,7 +545,13 @@ describe("ensureDetachedHubServer", () => {
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
})
|
||||
.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
@@ -336,7 +560,7 @@ describe("ensureDetachedHubServer", () => {
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
isHubDaemonProcess,
|
||||
isHubProtocolCompatible,
|
||||
resolveClineBuildEnv,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
@@ -15,17 +17,20 @@ import {
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
createHubServerUrl,
|
||||
type HubServerDiscoveryRecord,
|
||||
type HubOwnerContext,
|
||||
type HubServerProbeRecord,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveHubBuildId,
|
||||
} from "../discovery";
|
||||
import {
|
||||
type HubEndpointOverrides,
|
||||
resolveHubEndpointOptions,
|
||||
} from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
|
||||
const HUB_STARTUP_TIMEOUT_MS = 8_000;
|
||||
const HUB_STARTUP_POLL_MS = 200;
|
||||
@@ -54,16 +59,37 @@ function openDetachedHubLogFile(): { fd: number; logPath: string } | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function isCompatibleHubRecord(record: HubServerDiscoveryRecord): boolean {
|
||||
const recordBuildId = record.buildId?.trim();
|
||||
return !!recordBuildId && recordBuildId === resolveHubBuildId();
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function isCompatibleHubRecord(record: HubServerProbeRecord): boolean {
|
||||
return isHubProtocolCompatible(record).compatible;
|
||||
}
|
||||
|
||||
function withMatchingDiscoveryRetirementMetadata(
|
||||
probe: HubServerProbeRecord,
|
||||
discovered: { url?: string; authToken?: string; pid?: number } | undefined,
|
||||
expectedUrl: string,
|
||||
): HubServerProbeRecord {
|
||||
if (!discovered || discovered.url !== expectedUrl) {
|
||||
return probe;
|
||||
}
|
||||
return {
|
||||
...probe,
|
||||
authToken: probe.authToken ?? discovered.authToken,
|
||||
pid: probe.pid ?? discovered.pid,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeProbeHubServer(
|
||||
url: string,
|
||||
): Promise<HubServerDiscoveryRecord | undefined> {
|
||||
authToken?: string,
|
||||
): Promise<HubServerProbeRecord | undefined> {
|
||||
try {
|
||||
return await probeHubServer(url);
|
||||
return await probeHubServer(url, { authToken });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -84,13 +110,10 @@ async function waitForHubToRetire(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function retireIncompatibleHub(
|
||||
record: HubServerDiscoveryRecord,
|
||||
async function retireDiscoveredHub(
|
||||
record: { url: string; authToken?: string; pid?: number },
|
||||
discoveryPath: string,
|
||||
): Promise<void> {
|
||||
if (isCompatibleHubRecord(record)) {
|
||||
return;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
await requestHubShutdown(record.url, record.authToken).catch(() => false);
|
||||
if (record.pid) {
|
||||
try {
|
||||
@@ -99,8 +122,43 @@ async function retireIncompatibleHub(
|
||||
// Best-effort cleanup only. A compatible hub may still start on a fallback port.
|
||||
}
|
||||
}
|
||||
await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
|
||||
const retired = await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
|
||||
await clearHubDiscovery(discoveryPath).catch(() => undefined);
|
||||
return retired;
|
||||
}
|
||||
|
||||
async function retireIncompatibleHub(
|
||||
record: HubServerProbeRecord,
|
||||
discoveryPath: string,
|
||||
): Promise<boolean> {
|
||||
if (isCompatibleHubRecord(record)) {
|
||||
return true;
|
||||
}
|
||||
return retireDiscoveredHub(record, discoveryPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-singleton production builds tracked the local hub under the shared
|
||||
* owner discovery path and spawned daemons on random fallback ports. Those
|
||||
* daemons are invisible to the production owner context, so nothing would
|
||||
* ever reuse or stop them. Retire the recorded legacy hub (its record carries
|
||||
* the auth token and pid needed for a graceful stop) and clear the legacy
|
||||
* record so upgrades do not leave orphaned daemons running stale code.
|
||||
*/
|
||||
async function retireLegacySharedHub(owner: HubOwnerContext): Promise<void> {
|
||||
if (resolveClineBuildEnv() !== "production") {
|
||||
return;
|
||||
}
|
||||
const legacy = resolveSharedHubOwnerContext();
|
||||
if (legacy.discoveryPath === owner.discoveryPath) {
|
||||
return;
|
||||
}
|
||||
const record = await readHubDiscovery(legacy.discoveryPath);
|
||||
if (record?.url) {
|
||||
await retireDiscoveredHub(record, legacy.discoveryPath);
|
||||
} else {
|
||||
await clearHubDiscovery(legacy.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDaemonEntryPath(): string {
|
||||
@@ -203,48 +261,75 @@ export async function spawnDetachedHubServerWithRetry(
|
||||
|
||||
export function prewarmDetachedHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
endpoint: HubEndpointOverrides & { allowPortFallback?: boolean } = {},
|
||||
): void {
|
||||
if (isHubDaemonProcess()) {
|
||||
return;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const hasExplicitPort =
|
||||
endpoint.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const resolvedEndpoint = resolveHubEndpointOptions(endpoint);
|
||||
const expectedUrl = createHubServerUrl(
|
||||
resolvedEndpoint.host,
|
||||
resolvedEndpoint.port,
|
||||
resolvedEndpoint.pathname,
|
||||
);
|
||||
void readHubDiscovery(owner.discoveryPath)
|
||||
const shouldUseFallbackPort =
|
||||
endpoint.allowPortFallback === true && resolvedEndpoint.port !== 0;
|
||||
void retireLegacySharedHub(owner)
|
||||
.catch(() => undefined)
|
||||
.then(() => readHubDiscovery(owner.discoveryPath))
|
||||
.then(async (discovered) => {
|
||||
let retiredUnusableDiscovery = false;
|
||||
if (discovered?.url) {
|
||||
const healthy = await safeProbeHubServer(discovered.url);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discovered.authToken },
|
||||
if (!discovered.authToken) {
|
||||
retiredUnusableDiscovery = true;
|
||||
const retired = await retireDiscoveredHub(
|
||||
discovered,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (!retired && !shouldUseFallbackPort) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
const healthy = await safeProbeHubServer(
|
||||
discovered.url,
|
||||
discovered.authToken,
|
||||
);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discovered.authToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = await safeProbeHubServer(expectedUrl);
|
||||
if (expected?.url) {
|
||||
await retireIncompatibleHub(expected, owner.discoveryPath);
|
||||
if (isCompatibleHubRecord(expected)) {
|
||||
if (!shouldUseFallbackPort || !retiredUnusableDiscovery) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
{ ...expected, authToken: undefined },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (!retiredExpected && !shouldUseFallbackPort) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldUseFallbackPort =
|
||||
!hasExplicitPort && resolvedEndpoint.port !== 0;
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...resolvedEndpoint, port: 0 }
|
||||
: resolvedEndpoint;
|
||||
@@ -262,17 +347,16 @@ export interface DetachedHubResolution {
|
||||
|
||||
export async function ensureDetachedHubServer(
|
||||
workspaceRoot: string,
|
||||
endpointOverrides: HubEndpointOverrides = {},
|
||||
endpointOverrides: HubEndpointOverrides & {
|
||||
allowPortFallback?: boolean;
|
||||
} = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const hasExplicitEndpoint =
|
||||
endpointOverrides.host !== undefined ||
|
||||
endpointOverrides.port !== undefined ||
|
||||
endpointOverrides.pathname !== undefined ||
|
||||
!!process.env.CLINE_HUB_PORT?.trim();
|
||||
const hasExplicitPort =
|
||||
endpointOverrides.port !== undefined ||
|
||||
!!process.env.CLINE_HUB_PORT?.trim();
|
||||
const endpoint = resolveHubEndpointOptions(endpointOverrides);
|
||||
const expectedUrl = createHubServerUrl(
|
||||
endpoint.host,
|
||||
@@ -287,35 +371,72 @@ export async function ensureDetachedHubServer(
|
||||
}
|
||||
return result;
|
||||
};
|
||||
await retireLegacySharedHub(owner).catch(() => undefined);
|
||||
const discovered = await readHubDiscovery(owner.discoveryPath);
|
||||
let retiredUnusableDiscovery = false;
|
||||
if (discovered?.url) {
|
||||
const healthy = await safeProbeHubServer(discovered.url);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discovered.authToken,
|
||||
});
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discovered.authToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
const discoveredAuthToken = discovered.authToken;
|
||||
if (!discoveredAuthToken) {
|
||||
retiredUnusableDiscovery = true;
|
||||
await retireDiscoveredHub(discovered, owner.discoveryPath);
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
const healthy = await safeProbeHubServer(
|
||||
discovered.url,
|
||||
discoveredAuthToken,
|
||||
);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discoveredAuthToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discoveredAuthToken,
|
||||
});
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discoveredAuthToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = await safeProbeHubServer(expectedUrl);
|
||||
if (expected?.url) {
|
||||
await retireIncompatibleHub(expected, owner.discoveryPath);
|
||||
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
|
||||
expected,
|
||||
discovered,
|
||||
expectedUrl,
|
||||
);
|
||||
if (isCompatibleHubRecord(expected)) {
|
||||
const upgradeHint = retiredUnusableDiscovery
|
||||
? " This can happen immediately after upgrading from a build that wrote an empty hub auth token; run 'cline doctor fix' to stop the old daemon and repair local hub discovery."
|
||||
: "";
|
||||
throw new Error(
|
||||
`A compatible Cline Hub is already running at ${expectedUrl}, but its discovery record is missing or unreadable. Run 'cline doctor fix' to repair local hub discovery.${upgradeHint}`,
|
||||
);
|
||||
}
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (
|
||||
!retiredExpected &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
`An incompatible Cline Hub is already running at ${expectedUrl} and could not be retired automatically. Run 'cline doctor fix' to stop stale hub daemons before starting a new hub.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const shouldUseFallbackPort = !hasExplicitPort && endpoint.port !== 0;
|
||||
const shouldUseFallbackPort =
|
||||
endpointOverrides.allowPortFallback === true && endpoint.port !== 0;
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...endpoint, port: 0 }
|
||||
: endpoint;
|
||||
@@ -323,8 +444,11 @@ export async function ensureDetachedHubServer(
|
||||
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (nextDiscovery?.url) {
|
||||
const healthy = await safeProbeHubServer(nextDiscovery.url);
|
||||
if (nextDiscovery?.url && nextDiscovery.authToken) {
|
||||
const healthy = await safeProbeHubServer(
|
||||
nextDiscovery.url,
|
||||
nextDiscovery.authToken,
|
||||
);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
@@ -340,7 +464,24 @@ export async function ensureDetachedHubServer(
|
||||
}
|
||||
const nextExpected = await safeProbeHubServer(expectedUrl);
|
||||
if (nextExpected?.url && !isCompatibleHubRecord(nextExpected)) {
|
||||
await retireIncompatibleHub(nextExpected, owner.discoveryPath);
|
||||
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
|
||||
nextExpected,
|
||||
nextDiscovery,
|
||||
expectedUrl,
|
||||
);
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (
|
||||
!retiredExpected &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
`An incompatible Cline Hub is still running at ${expectedUrl} and could not be retired automatically. Run 'cline doctor fix' to stop stale hub daemons before starting a new hub.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, HUB_STARTUP_POLL_MS));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EnsureHubServerOptions } from "./start-shared-server";
|
||||
|
||||
const {
|
||||
mockEnsureHubWebSocketServer,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockResolveClineBuildEnv,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStartHubWebSocketServer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureHubWebSocketServer: vi.fn(async () => ({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
action: "started",
|
||||
})),
|
||||
mockResolveHubEndpointOptions: vi.fn(
|
||||
(options: { host?: string; port?: number; pathname?: string }) => ({
|
||||
host: options.host ?? "127.0.0.1",
|
||||
port: options.port ?? 25463,
|
||||
pathname: options.pathname ?? "/hub",
|
||||
}),
|
||||
),
|
||||
mockResolveClineBuildEnv: vi.fn(() => "production"),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
})),
|
||||
mockStartHubWebSocketServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/shared", () => ({
|
||||
resolveClineBuildEnv: mockResolveClineBuildEnv,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/defaults", () => ({
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
vi.mock("../server", () => ({
|
||||
ensureHubWebSocketServer: mockEnsureHubWebSocketServer,
|
||||
startHubWebSocketServer: mockStartHubWebSocketServer,
|
||||
}));
|
||||
|
||||
const originalHubPort = process.env.CLINE_HUB_PORT;
|
||||
const runtimeHandlers =
|
||||
{} as unknown as EnsureHubServerOptions["runtimeHandlers"];
|
||||
|
||||
describe("ensureHubServer", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureHubWebSocketServer.mockClear();
|
||||
mockResolveHubEndpointOptions.mockClear();
|
||||
mockResolveClineBuildEnv.mockClear();
|
||||
mockResolveClineBuildEnv.mockReturnValue("production");
|
||||
mockResolveProductionHubOwnerContext.mockClear();
|
||||
mockResolveSharedHubOwnerContext.mockClear();
|
||||
mockStartHubWebSocketServer.mockClear();
|
||||
if (originalHubPort === undefined) {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
} else {
|
||||
process.env.CLINE_HUB_PORT = originalHubPort;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not allow port fallback by default in production", async () => {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 25463,
|
||||
allowPortFallback: false,
|
||||
owner: expect.objectContaining({
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows port fallback by default in development when no port is explicit", async () => {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
mockResolveClineBuildEnv.mockReturnValue("development");
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 25463,
|
||||
allowPortFallback: true,
|
||||
owner: expect.objectContaining({
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not default port fallback when a port option is explicit", async () => {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ port: 30000, runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 30000,
|
||||
allowPortFallback: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not default port fallback when CLINE_HUB_PORT is explicit", async () => {
|
||||
process.env.CLINE_HUB_PORT = "30001";
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowPortFallback: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import {
|
||||
type EnsuredHubWebSocketServerResult,
|
||||
type EnsureHubWebSocketServerOptions,
|
||||
@@ -18,9 +22,19 @@ export interface StartHubServerOptions
|
||||
export interface EnsureHubServerOptions
|
||||
extends Omit<EnsureHubWebSocketServerOptions, "owner"> {}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function shouldAllowDefaultPortFallback(hasExplicitPort: boolean): boolean {
|
||||
return resolveClineBuildEnv() !== "production" && !hasExplicitPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a hub WebSocket server bound to the process-local shared owner
|
||||
* context. Callers that need a custom owner should invoke
|
||||
* Start a hub WebSocket server bound to the default owner context for the
|
||||
* current build environment. Callers that need a custom owner should invoke
|
||||
* {@link startHubWebSocketServer} directly.
|
||||
*/
|
||||
export async function startHubServer(
|
||||
@@ -34,13 +48,13 @@ export async function startHubServer(
|
||||
return await startHubWebSocketServer({
|
||||
...options,
|
||||
...endpoint,
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
owner: resolveDefaultHubOwnerContext(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a hub WebSocket server is running in the process-local shared owner
|
||||
* context, reusing a compatible in-process instance when available.
|
||||
* Ensure a hub WebSocket server is running in the default owner context for the
|
||||
* current build environment, reusing a compatible in-process instance when available.
|
||||
*/
|
||||
export async function ensureHubServer(
|
||||
options: EnsureHubServerOptions,
|
||||
@@ -55,7 +69,9 @@ export async function ensureHubServer(
|
||||
return await ensureHubWebSocketServer({
|
||||
...options,
|
||||
...endpoint,
|
||||
allowPortFallback: options.allowPortFallback ?? !hasExplicitPort,
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
allowPortFallback:
|
||||
options.allowPortFallback ??
|
||||
shouldAllowDefaultPortFallback(hasExplicitPort),
|
||||
owner: resolveDefaultHubOwnerContext(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveHubOwnerContext,
|
||||
writeHubDiscovery,
|
||||
@@ -88,4 +89,62 @@ describe("hub discovery", () => {
|
||||
await clearHubDiscovery(discoveryPath);
|
||||
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects discovery records without an auth token", async () => {
|
||||
snapshot = captureEnv();
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
|
||||
const discoveryPath = resolveHubOwnerContext("missing-auth").discoveryPath;
|
||||
await mkdir(dirname(discoveryPath), { recursive: true });
|
||||
await writeFile(
|
||||
discoveryPath,
|
||||
`${JSON.stringify({
|
||||
hubId: "hub_123",
|
||||
protocolVersion: "v1",
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns only public health fields for unauthenticated probes", async () => {
|
||||
const fetchMock = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
protocolVersion: "v1",
|
||||
minClientProtocolVersion: "v1",
|
||||
maxClientProtocolVersion: "v1",
|
||||
coreVersion: "1.0.0",
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
}),
|
||||
}) as Response;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
try {
|
||||
const record = await probeHubServer("ws://127.0.0.1:25463/hub");
|
||||
|
||||
expect(record).toMatchObject({
|
||||
protocolVersion: "v1",
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
expect(record?.hubId).toBeUndefined();
|
||||
expect(record?.startedAt).toBeUndefined();
|
||||
expect(record?.updatedAt).toBeUndefined();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ const HUB_STARTUP_LOCK_POLL_MS = 100;
|
||||
export interface HubServerDiscoveryRecord {
|
||||
hubId: string;
|
||||
protocolVersion: string;
|
||||
minClientProtocolVersion?: string;
|
||||
maxClientProtocolVersion?: string;
|
||||
capabilities?: readonly string[];
|
||||
coreVersion?: string;
|
||||
buildId?: string;
|
||||
authToken: string;
|
||||
@@ -25,6 +28,23 @@ export interface HubServerDiscoveryRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type HubServerProbeRecord = {
|
||||
protocolVersion: string;
|
||||
minClientProtocolVersion?: string;
|
||||
maxClientProtocolVersion?: string;
|
||||
capabilities?: readonly string[];
|
||||
coreVersion?: string;
|
||||
buildId?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
url: string;
|
||||
hubId?: string;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
startedAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export interface HubOwnerContext {
|
||||
ownerId: string;
|
||||
discoveryPath: string;
|
||||
@@ -135,6 +155,20 @@ export async function readHubDiscovery(
|
||||
return {
|
||||
hubId: parsed.hubId,
|
||||
protocolVersion: parsed.protocolVersion,
|
||||
minClientProtocolVersion:
|
||||
typeof parsed.minClientProtocolVersion === "string"
|
||||
? parsed.minClientProtocolVersion
|
||||
: undefined,
|
||||
maxClientProtocolVersion:
|
||||
typeof parsed.maxClientProtocolVersion === "string"
|
||||
? parsed.maxClientProtocolVersion
|
||||
: undefined,
|
||||
capabilities: Array.isArray(parsed.capabilities)
|
||||
? parsed.capabilities.filter(
|
||||
(capability): capability is string =>
|
||||
typeof capability === "string",
|
||||
)
|
||||
: undefined,
|
||||
coreVersion:
|
||||
typeof parsed.coreVersion === "string" ? parsed.coreVersion : undefined,
|
||||
buildId: typeof parsed.buildId === "string" ? parsed.buildId : undefined,
|
||||
@@ -225,13 +259,60 @@ export async function withHubStartupLock<T>(
|
||||
|
||||
export async function probeHubServer(
|
||||
url: string,
|
||||
): Promise<HubServerDiscoveryRecord | undefined> {
|
||||
options?: { authToken?: string },
|
||||
): Promise<HubServerProbeRecord | undefined> {
|
||||
try {
|
||||
const response = await fetch(toHubHealthUrl(url));
|
||||
const response = await fetch(
|
||||
options?.authToken ? toHubStatusUrl(url) : toHubHealthUrl(url),
|
||||
{
|
||||
headers: options?.authToken
|
||||
? { authorization: `Bearer ${options.authToken}` }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
return (await response.json()) as HubServerDiscoveryRecord;
|
||||
const parsed = (await response.json()) as Partial<HubServerProbeRecord>;
|
||||
if (
|
||||
typeof parsed.protocolVersion !== "string" ||
|
||||
typeof parsed.host !== "string" ||
|
||||
typeof parsed.port !== "number" ||
|
||||
typeof parsed.url !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
protocolVersion: parsed.protocolVersion,
|
||||
minClientProtocolVersion:
|
||||
typeof parsed.minClientProtocolVersion === "string"
|
||||
? parsed.minClientProtocolVersion
|
||||
: undefined,
|
||||
maxClientProtocolVersion:
|
||||
typeof parsed.maxClientProtocolVersion === "string"
|
||||
? parsed.maxClientProtocolVersion
|
||||
: undefined,
|
||||
capabilities: Array.isArray(parsed.capabilities)
|
||||
? parsed.capabilities.filter(
|
||||
(capability): capability is string =>
|
||||
typeof capability === "string",
|
||||
)
|
||||
: undefined,
|
||||
coreVersion:
|
||||
typeof parsed.coreVersion === "string" ? parsed.coreVersion : undefined,
|
||||
buildId: typeof parsed.buildId === "string" ? parsed.buildId : undefined,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
url: parsed.url,
|
||||
hubId: typeof parsed.hubId === "string" ? parsed.hubId : undefined,
|
||||
authToken:
|
||||
typeof parsed.authToken === "string" ? parsed.authToken : undefined,
|
||||
pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
|
||||
startedAt:
|
||||
typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
|
||||
updatedAt:
|
||||
typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -253,6 +334,12 @@ export function toHubHealthUrl(wsUrl: string): string {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function toHubStatusUrl(wsUrl: string): string {
|
||||
const parsed = new URL(toHubHealthUrl(wsUrl));
|
||||
parsed.pathname = "/status";
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function isDiscoveryFilePresent(pathname: string): boolean {
|
||||
return existsSync(pathname);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { join } from "node:path";
|
||||
import { normalizeWorkspacePath } from "../../services/workspace/workspace-manifest";
|
||||
import { type HubOwnerContext, resolveHubOwnerContext } from ".";
|
||||
import {
|
||||
type HubOwnerContext,
|
||||
resolveClineDataDir,
|
||||
resolveHubOwnerContext,
|
||||
} from ".";
|
||||
|
||||
const DEFAULT_SHARED_HUB_OWNER_LABEL = "shared:cline";
|
||||
const HUB_DISCOVERY_ENV = "CLINE_HUB_DISCOVERY_PATH";
|
||||
const PRODUCTION_HUB_OWNER_ID = "hub-production";
|
||||
|
||||
export function resolveWorkspaceHubOwnerContext(
|
||||
workspaceRoot: string,
|
||||
@@ -17,3 +24,12 @@ export function resolveSharedHubOwnerContext(
|
||||
): HubOwnerContext {
|
||||
return resolveHubOwnerContext(label);
|
||||
}
|
||||
|
||||
export function resolveProductionHubOwnerContext(): HubOwnerContext {
|
||||
return {
|
||||
ownerId: PRODUCTION_HUB_OWNER_ID,
|
||||
discoveryPath:
|
||||
process.env[HUB_DISCOVERY_ENV]?.trim() ||
|
||||
join(resolveClineDataDir(), "locks", "hub", "production.json"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readBearerToken } from "./hub-websocket-server";
|
||||
|
||||
describe("readBearerToken", () => {
|
||||
it("reads a bearer token with case-insensitive scheme", () => {
|
||||
expect(readBearerToken("Bearer token")).toBe("token");
|
||||
expect(readBearerToken("bearer token")).toBe("token");
|
||||
});
|
||||
|
||||
it("reads a bearer token separated by tabs without regex backtracking", () => {
|
||||
expect(readBearerToken(`bearer\t\t${"token"}`)).toBe("token");
|
||||
expect(readBearerToken(`bearer${"\t".repeat(10_000)}token`)).toBe("token");
|
||||
});
|
||||
|
||||
it("rejects missing and malformed bearer tokens", () => {
|
||||
expect(readBearerToken(undefined)).toBeNull();
|
||||
expect(readBearerToken("Bearer")).toBeNull();
|
||||
expect(readBearerToken("BearerToken")).toBeNull();
|
||||
expect(readBearerToken("Basic token")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import { timingSafeEqual } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { URL } from "node:url";
|
||||
import {
|
||||
CURRENT_HUB_PROTOCOL_VERSION,
|
||||
HUB_CAPABILITIES,
|
||||
isHubProtocolCompatible,
|
||||
MAX_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
MIN_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
} from "@cline/shared";
|
||||
import { WebSocketServer } from "ws";
|
||||
import corePackage from "../../../package.json";
|
||||
import { rememberRecoverableLocalHubUrl, verifyHubConnection } from "../client";
|
||||
@@ -204,10 +211,32 @@ function parseHeaderValue(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value.join(",") : (value ?? "");
|
||||
}
|
||||
|
||||
function readBearerToken(value: string | string[] | undefined): string | null {
|
||||
function isAuthHeaderWhitespace(code: number): boolean {
|
||||
return code === 0x20 || code === 0x09;
|
||||
}
|
||||
|
||||
export function readBearerToken(
|
||||
value: string | string[] | undefined,
|
||||
): string | null {
|
||||
const header = parseHeaderValue(value).trim();
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
return match?.[1]?.trim() || null;
|
||||
const bearerScheme = "bearer";
|
||||
if (
|
||||
header.length <= bearerScheme.length ||
|
||||
header.slice(0, bearerScheme.length).toLowerCase() !== bearerScheme ||
|
||||
!isAuthHeaderWhitespace(header.charCodeAt(bearerScheme.length))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tokenStart = bearerScheme.length + 1;
|
||||
while (
|
||||
tokenStart < header.length &&
|
||||
isAuthHeaderWhitespace(header.charCodeAt(tokenStart))
|
||||
) {
|
||||
tokenStart += 1;
|
||||
}
|
||||
|
||||
return header.slice(tokenStart).trim() || null;
|
||||
}
|
||||
|
||||
function readWebSocketAuthToken(
|
||||
@@ -244,7 +273,10 @@ export async function startHubWebSocketServer(
|
||||
const cleanup = new Set<() => void>();
|
||||
const startedAt = new Date().toISOString();
|
||||
const versionPayload = {
|
||||
protocolVersion: "v1",
|
||||
protocolVersion: CURRENT_HUB_PROTOCOL_VERSION,
|
||||
minClientProtocolVersion: MIN_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
maxClientProtocolVersion: MAX_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
capabilities: HUB_CAPABILITIES,
|
||||
coreVersion: corePackage.version,
|
||||
buildId,
|
||||
pid: process.pid,
|
||||
@@ -300,10 +332,36 @@ export async function startHubWebSocketServer(
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url ?? "/") === "/health") {
|
||||
const body = JSON.stringify({
|
||||
ok: true,
|
||||
protocolVersion: versionPayload.protocolVersion,
|
||||
minClientProtocolVersion: versionPayload.minClientProtocolVersion,
|
||||
maxClientProtocolVersion: versionPayload.maxClientProtocolVersion,
|
||||
coreVersion: versionPayload.coreVersion,
|
||||
host,
|
||||
port,
|
||||
url,
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
if ((req.url ?? "/") === "/status") {
|
||||
if (
|
||||
!isValidHubAuthToken(
|
||||
readBearerToken(req.headers.authorization),
|
||||
authToken,
|
||||
)
|
||||
) {
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
hubId: transport.getHubId(),
|
||||
...versionPayload,
|
||||
authToken: "",
|
||||
authToken,
|
||||
host,
|
||||
port,
|
||||
url,
|
||||
@@ -449,7 +507,10 @@ export async function startHubWebSocketServer(
|
||||
|
||||
await writeHubDiscovery(owner.discoveryPath, {
|
||||
hubId: transport.getHubId(),
|
||||
protocolVersion: "v1",
|
||||
protocolVersion: CURRENT_HUB_PROTOCOL_VERSION,
|
||||
minClientProtocolVersion: MIN_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
maxClientProtocolVersion: MAX_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
capabilities: [...versionPayload.capabilities],
|
||||
coreVersion: corePackage.version,
|
||||
buildId,
|
||||
authToken,
|
||||
@@ -511,9 +572,12 @@ export async function ensureHubWebSocketServer(
|
||||
discovered?.url &&
|
||||
(discovered.url === expectedUrl || options.allowPortFallback === true);
|
||||
if (canReuseDiscovered) {
|
||||
const healthy = await probeHubServer(discovered.url);
|
||||
const healthy = await probeHubServer(discovered.url, {
|
||||
authToken: discovered.authToken,
|
||||
});
|
||||
if (
|
||||
healthy?.url &&
|
||||
isHubProtocolCompatible(healthy).compatible &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
@@ -526,8 +590,9 @@ export async function ensureHubWebSocketServer(
|
||||
}
|
||||
}
|
||||
|
||||
const expected = await probeHubServer(expectedUrl);
|
||||
if (expected?.url || discovered?.url) {
|
||||
// The discovered hub was not reusable (missing, mismatched, or failed
|
||||
// verification), so its record is stale either way.
|
||||
if (discovered?.url) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isHubProtocolCompatible } from "./hub";
|
||||
|
||||
describe("isHubProtocolCompatible", () => {
|
||||
it("accepts a hub whose supported client range includes the client protocol", () => {
|
||||
expect(
|
||||
isHubProtocolCompatible({
|
||||
protocolVersion: "v2",
|
||||
minClientProtocolVersion: "v1",
|
||||
maxClientProtocolVersion: "v2",
|
||||
}),
|
||||
).toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
it("rejects a hub whose supported client range excludes the client protocol", () => {
|
||||
expect(
|
||||
isHubProtocolCompatible({
|
||||
protocolVersion: "v2",
|
||||
minClientProtocolVersion: "v2",
|
||||
maxClientProtocolVersion: "v3",
|
||||
}),
|
||||
).toEqual({ compatible: false, reason: "unsupported_protocol" });
|
||||
});
|
||||
|
||||
it("rejects missing or malformed protocol versions", () => {
|
||||
expect(isHubProtocolCompatible({ protocolVersion: "" })).toEqual({
|
||||
compatible: false,
|
||||
reason: "missing_protocol",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,77 @@ import type { RuntimeConfigExtensionKind } from "./session/runtime-config";
|
||||
|
||||
export type HubProtocolVersion = "v1";
|
||||
|
||||
export const CURRENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
|
||||
export const MIN_CLIENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
|
||||
export const MAX_CLIENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
|
||||
|
||||
export type HubCapabilityName =
|
||||
| "client.register"
|
||||
| "client.list"
|
||||
| "session.create"
|
||||
| "session.list"
|
||||
| "session.get"
|
||||
| "session.run"
|
||||
| "session.abort"
|
||||
| "schedule.create"
|
||||
| "schedule.list"
|
||||
| "settings.get"
|
||||
| "settings.set";
|
||||
|
||||
export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [
|
||||
"client.register",
|
||||
"client.list",
|
||||
"session.create",
|
||||
"session.list",
|
||||
"session.get",
|
||||
"session.run",
|
||||
"session.abort",
|
||||
"schedule.create",
|
||||
"schedule.list",
|
||||
"settings.get",
|
||||
"settings.set",
|
||||
];
|
||||
|
||||
export interface HubProtocolMetadata {
|
||||
protocolVersion: string;
|
||||
minClientProtocolVersion?: string;
|
||||
maxClientProtocolVersion?: string;
|
||||
capabilities?: readonly string[];
|
||||
}
|
||||
|
||||
export type HubCompatibilityResult =
|
||||
| { compatible: true }
|
||||
| { compatible: false; reason: "missing_protocol" | "unsupported_protocol" };
|
||||
|
||||
function parseHubProtocolNumber(
|
||||
version: string | undefined,
|
||||
): number | undefined {
|
||||
const match = /^v(\d+)$/.exec(version?.trim() ?? "");
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return Number.parseInt(match[1] ?? "", 10);
|
||||
}
|
||||
|
||||
export function isHubProtocolCompatible(
|
||||
hub: HubProtocolMetadata,
|
||||
clientProtocolVersion: HubProtocolVersion = CURRENT_HUB_PROTOCOL_VERSION,
|
||||
): HubCompatibilityResult {
|
||||
const hubProtocol = parseHubProtocolNumber(hub.protocolVersion);
|
||||
const clientProtocol = parseHubProtocolNumber(clientProtocolVersion);
|
||||
if (hubProtocol === undefined || clientProtocol === undefined) {
|
||||
return { compatible: false, reason: "missing_protocol" };
|
||||
}
|
||||
const minClientProtocol =
|
||||
parseHubProtocolNumber(hub.minClientProtocolVersion) ?? hubProtocol;
|
||||
const maxClientProtocol =
|
||||
parseHubProtocolNumber(hub.maxClientProtocolVersion) ?? hubProtocol;
|
||||
return clientProtocol >= minClientProtocol &&
|
||||
clientProtocol <= maxClientProtocol
|
||||
? { compatible: true }
|
||||
: { compatible: false, reason: "unsupported_protocol" };
|
||||
}
|
||||
|
||||
export type HubActorKind = "client" | "peerHub";
|
||||
|
||||
export type HubTransportKind =
|
||||
|
||||
Reference in New Issue
Block a user