Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/proxy/src/__tests__/conversations-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,25 @@ describe("POST /api/conversations", () => {
mockScanDiskConversations.mockResolvedValue([]);
});

it("does not fall back to a different engine when the selected engine is unavailable", async () => {
mockGetInstances.mockResolvedValue([]);

const res = await app().request("/api/conversations", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-porta-target-app": "antigravity",
},
body: JSON.stringify({}),
});

expect(res.status).toBe(503);
await expect(res.json()).resolves.toEqual({
error: "No running Language Server found for antigravity.",
});
expect(mockRpcCall).not.toHaveBeenCalled();
});

it("sets the Antigravity 2.x required trajectory source and caches unscoped hub ownership", async () => {
const hubLS = makeInstance({ pid: 4, workspaceId: undefined });
mockGetInstances.mockResolvedValue([hubLS]);
Expand Down
1 change: 1 addition & 0 deletions packages/proxy/src/__tests__/workspaces-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const mockRpcCall = vi.fn<
vi.mock("../routing.js", () => ({
discovery: { getInstances: mockGetInstances },
rpc: { call: mockRpcCall },
extractTargetAppDataDir: () => undefined,
}));

const { registerWorkspaceRoutes } = await import("../routes/workspaces.js");
Expand Down
14 changes: 14 additions & 0 deletions packages/proxy/src/__tests__/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ describe("WS upgrade validation", () => {
).toEqual({ ok: true, cascadeId: "abc123" });
});

it("keeps the selected target on the WebSocket upgrade", () => {
expect(
validateWebSocketUpgrade(
"/api/conversations/abc123/ws?targetApp=antigravity-ide",
"http://localhost:5173",
3100,
),
).toEqual({
ok: true,
cascadeId: "abc123",
targetApp: "antigravity-ide",
});
});

it("rejects cross-origin upgrades on the WS endpoint", () => {
const allowedOrigins = getAllowedOrigins({});

Expand Down
58 changes: 34 additions & 24 deletions packages/proxy/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,45 +408,55 @@ export class LSDiscovery {
return discoverInstances();
}

async getInstances(forceRefresh = false): Promise<LSInstance[]> {
async getInstances(
forceRefresh = false,
targetAppDataDir?: string,
): Promise<LSInstance[]> {
const now = Date.now();
const cacheFresh =
!forceRefresh &&
this.instances.length > 0 &&
now - this.lastDiscovery <= this.ttlMs;

let instances: LSInstance[];
if (cacheFresh) {
return this.instances;
}
instances = this.instances;
} else if (!forceRefresh && this.pendingDiscovery) {
instances = await this.pendingDiscovery;
} else {
const generation = ++this.discoveryGeneration;
const pending = this.discover()
.then((discovered) => {
if (generation === this.discoveryGeneration) {
this.instances = discovered;
this.lastDiscovery = Date.now();
}
return discovered;
})
.finally(() => {
if (this.pendingDiscovery === pending) {
this.pendingDiscovery = null;
}
});

if (!forceRefresh && this.pendingDiscovery) {
return this.pendingDiscovery;
this.pendingDiscovery = pending;
instances = await pending;
}

const generation = ++this.discoveryGeneration;
const pending = this.discover()
.then((instances) => {
if (generation === this.discoveryGeneration) {
this.instances = instances;
this.lastDiscovery = Date.now();
}
return instances;
})
.finally(() => {
if (this.pendingDiscovery === pending) {
this.pendingDiscovery = null;
}
});

this.pendingDiscovery = pending;
return pending;
if (targetAppDataDir && targetAppDataDir !== "all") {
return instances.filter((inst) => inst.appDataDir === targetAppDataDir);
}
return instances;
}

/**
* Get the first available instance (or a specific workspace).
*/
async getInstance(workspaceId?: string): Promise<LSInstance | null> {
const instances = await this.getInstances();
async getInstance(
workspaceId?: string,
targetAppDataDir?: string,
): Promise<LSInstance | null> {
const instances = await this.getInstances(false, targetAppDataDir);

if (workspaceId) {
return instances.find((inst) => inst.workspaceId === workspaceId) ?? null;
Expand Down
8 changes: 6 additions & 2 deletions packages/proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { createAdaptorServer } from "@hono/node-server";

import { discovery } from "./routing.js";
import { discovery, extractTargetAppDataDir } from "./routing.js";
import { registerConversationRoutes } from "./routes/conversations.js";
import { registerModelRoutes } from "./routes/models.js";
import { registerWorkspaceRoutes } from "./routes/workspaces.js";
Expand Down Expand Up @@ -45,14 +45,18 @@ app.use(
// ── Health ──

app.get("/api/health", async (c) => {
const instances = await discovery.getInstances();
const instances = await discovery.getInstances(
false,
extractTargetAppDataDir(c),
);
return c.json({
status: "ok",
proxy: { port: PORT, uptime: process.uptime() },
languageServers: instances.map((i) => ({
pid: i.pid,
httpsPort: i.httpsPort,
workspaceId: i.workspaceId,
appDataDir: i.appDataDir,
source: i.source,
})),
});
Expand Down
10 changes: 6 additions & 4 deletions packages/proxy/src/platform/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ function escapeRegExp(value: string): string {
}

function parseExecutable(args: string): string | undefined {
const match = args.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s|$)/);
return match?.[1] ?? match?.[2] ?? match?.[3];
const match = args.match(/^(?:["']?([^"']+)["']?)/);
const raw = match?.[1] ?? args;
const lsMatch = raw.match(/^(.*?language_server[^\s]*)/i);
return (lsMatch?.[1] ?? raw).trim();
}

export function isLanguageServerExecutable(value: string): boolean {
return LANGUAGE_SERVER_EXECUTABLE.test(value.trim());
return /(?:^|[\\/])language_server(?:_[^/\\\s"']+)?(?:\.exe)?$/i.test(value.trim());
}

function parseArgValue(args: string, flag: string): string | undefined {
Expand Down Expand Up @@ -173,7 +175,7 @@ export function parseLsofPorts(output: string): number[] {
const line = rawLine.trim();
if (!line || !line.includes("(LISTEN)")) continue;

const match = line.match(/:(\d+)\s+\(LISTEN\)$/);
const match = line.match(/:(\d+)\s+\(LISTEN\)/);
if (!match) continue;

const port = parseInt(match[1], 10);
Expand Down
Loading
Loading