IDE companion discovery: switch to ~/.qwen/ide lock files

This commit is contained in:
tanzhenxin
2025-12-15 20:15:26 +08:00
parent 4cbb57a793
commit 59c3d3d0f9
11 changed files with 297 additions and 294 deletions

View File

@@ -15,6 +15,7 @@ export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
export class Storage {
private readonly targetDir: string;
@@ -59,6 +60,10 @@ export class Storage {
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
}
static getGlobalIdeDir(): string {
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
}
static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
}

View File

@@ -338,10 +338,7 @@ describe('OpenAIContentConverter', () => {
});
it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [
{} as Tool,
{ functionDeclarations: [] },
];
const emptyTools: Tool[] = [{} as Tool, { functionDeclarations: [] }];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
@@ -489,7 +486,10 @@ describe('OpenAIContentConverter', () => {
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<
string,
unknown
>;
expect(nestedProperties?.['deep']).toEqual({
type: 'integer',

View File

@@ -32,6 +32,7 @@ vi.mock('node:fs', async (importOriginal) => {
...actual.promises,
readFile: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
@@ -68,6 +69,7 @@ describe('IdeClient', () => {
command: 'test-ide',
});
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
vi.mocked(os.homedir).mockReturnValue('/home/test');
// Mock MCP client and transports
mockClient = {
@@ -97,6 +99,7 @@ describe('IdeClient', () => {
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
@@ -109,7 +112,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
path.join('/home/test', '.qwen', 'ide', '12345-8080.lock'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -120,6 +123,7 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using stdio when stdio config is provided in file', async () => {
@@ -263,7 +267,8 @@ describe('IdeClient', () => {
});
describe('getConnectionConfigFromFile', () => {
it('should return config from the specific pid file if it exists', async () => {
it('should return config from the env port lock file if it exists', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';
const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
@@ -277,9 +282,10 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
path.join('/home/test', '.qwen', 'ide', '12345-1234.lock'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should return undefined if no config files are found', async () => {
@@ -300,17 +306,23 @@ describe('IdeClient', () => {
expect(result).toBeUndefined();
});
it('should find and parse a single config file with the new naming scheme', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
); // For old path
it('should find and parse a single lock file matching the IDE pid', async () => {
const config = {
port: '5678',
workspacePath: '/test/workspace',
ppid: 12345,
};
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
).mockResolvedValueOnce(['12345-5678.lock']);
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
mtimeMs: 123,
} as unknown as fs.Stats);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(config),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
@@ -324,7 +336,7 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
path.join('/home/test', '.qwen', 'ide', '12345-5678.lock'),
'utf8',
);
});
@@ -338,25 +350,23 @@ describe('IdeClient', () => {
port: '1111',
workspacePath: '/invalid/workspace',
};
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false })
.mockReturnValueOnce({ isValid: true });
.mockImplementation((ideWorkspacePath) => ({
isValid: ideWorkspacePath === '/test/workspace',
}));
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -379,17 +389,15 @@ describe('IdeClient', () => {
it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
// Make config1 "newer" so it wins when both are valid.
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
@@ -413,15 +421,15 @@ describe('IdeClient', () => {
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
); // For ~/.qwen/ide/<pid>-<port>.lock
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
@@ -442,17 +450,14 @@ describe('IdeClient', () => {
it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig));
@@ -474,9 +479,11 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
vi.mocked(fs.promises.readdir).mockRejectedValue(
new Error('readdir failed'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -490,18 +497,19 @@ describe('IdeClient', () => {
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json', // valid
).mockResolvedValueOnce([
'12345-3333.lock', // valid
'not-a-config-file.txt', // invalid
'qwen-code-ide-server-asdf.json', // invalid
]);
'asdf.lock', // invalid
'12345-asdf.lock', // invalid
]); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
mtimeMs: 123,
} as unknown as fs.Stats);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
@@ -518,11 +526,11 @@ describe('IdeClient', () => {
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
path.join('/home/test', '.qwen', 'ide', '12345-3333.lock'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
path.join('/home/test', '.qwen', 'ide', 'not-a-config-file.txt'),
'utf8',
);
});
@@ -538,10 +546,10 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));

View File

@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js';
import { Storage } from '../config/storage.js';
import {
IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema,
@@ -576,7 +577,85 @@ export class IdeClient {
return undefined;
}
// For backwards compatability
// Preferred: lock file(s) in global qwen dir (~/.qwen/ide/<pid>-<port>.lock)
// 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/<pid>-<port>.lock
// 2) Otherwise (or on failure), scan ~/.qwen/ide for <pid>-*.lock and select:
// - valid workspace path (validateWorkspacePath)
const ideDir = Storage.getGlobalIdeDir();
const idePid = this.ideProcessInfo.pid;
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
try {
const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`);
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
return JSON.parse(lockFileContents);
} catch (_) {
// Fall through to scanning / legacy discovery.
}
}
try {
const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`);
const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
);
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
const stat = await fs.promises.stat(fullPath);
const content = await fs.promises.readFile(fullPath, 'utf8');
try {
const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) {
logger.debug('Failed to parse JSON from lock file: ', e);
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
}
} catch (e) {
// If we can't stat/read the file, treat it as very old so it doesn't
// win ties, and skip parsing by returning undefined content.
logger.debug('Failed to read/stat IDE lock file:', e);
return { file, mtimeMs: -Infinity, parsed: undefined };
}
}),
);
const validWorkspaces = fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed)
.filter((content) => {
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length > 0) {
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
if (validWorkspaces.length > 1 && portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
}
}
if (validWorkspaces.length > 1) {
return validWorkspaces[0];
}
}
} catch (_) {
// Fall through to legacy discovery mechanisms.
}
// For backwards compatability: single file in system temp dir named by PID.
try {
const portFile = path.join(
os.tmpdir(),
@@ -585,85 +664,13 @@ export class IdeClient {
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For newer extension versions, the file name matches the pattern
// For older/newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return undefined;
}
if (!portFiles) {
return undefined;
}
const fileRegex = new RegExp(
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
let fileContents: string[];
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
return undefined;
}
const parsedContents = fileContents.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
});
const validWorkspaces = parsedContents.filter((content) => {
if (!content) {
return false;
}
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
}
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
}
}
return validWorkspaces[0];
return undefined;
}
private createProxyAwareFetch() {