mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
Merge remote-tracking branch 'origin/main' into refactor/task-to-agent-tool
This commit is contained in:
commit
3a686d5ad1
49 changed files with 3624 additions and 315 deletions
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { Storage } from './storage.js';
|
||||
|
|
@ -40,3 +40,321 @@ describe('Storage – additional helpers', () => {
|
|||
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage – getRuntimeBaseDir / setRuntimeBaseDir', () => {
|
||||
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset state before each test
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
if (originalEnv !== undefined) {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
|
||||
} else {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to getGlobalQwenDir() when nothing is configured', () => {
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
|
||||
it('uses setRuntimeBaseDir value when set with absolute path', () => {
|
||||
const runtimeDir = path.resolve('custom', 'runtime');
|
||||
Storage.setRuntimeBaseDir(runtimeDir);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir);
|
||||
});
|
||||
|
||||
it('env var QWEN_RUNTIME_DIR takes priority over setRuntimeBaseDir', () => {
|
||||
const settingsDir = path.resolve('from-settings');
|
||||
const envDir = path.resolve('from-env');
|
||||
Storage.setRuntimeBaseDir(settingsDir);
|
||||
process.env['QWEN_RUNTIME_DIR'] = envDir;
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(envDir);
|
||||
});
|
||||
|
||||
it('expands tilde (~) in setRuntimeBaseDir', () => {
|
||||
Storage.setRuntimeBaseDir('~/custom-runtime');
|
||||
const expected = path.join(os.homedir(), 'custom-runtime');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('expands Windows-style tilde paths in setRuntimeBaseDir', () => {
|
||||
Storage.setRuntimeBaseDir('~\\custom-runtime');
|
||||
const expected = path.join(os.homedir(), 'custom-runtime');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('expands tilde (~) in QWEN_RUNTIME_DIR env var', () => {
|
||||
process.env['QWEN_RUNTIME_DIR'] = '~/env-runtime';
|
||||
const expected = path.join(os.homedir(), 'env-runtime');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('resolves relative paths in setRuntimeBaseDir using process.cwd by default', () => {
|
||||
Storage.setRuntimeBaseDir('relative/path');
|
||||
const expected = path.resolve('relative/path');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('resolves relative paths in setRuntimeBaseDir using explicit cwd', () => {
|
||||
const cwd = path.resolve('workspace', 'projectA');
|
||||
Storage.setRuntimeBaseDir('.qwen', cwd);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen'));
|
||||
});
|
||||
|
||||
it('ignores cwd when path is absolute', () => {
|
||||
const absolutePath = path.resolve('absolute', 'path');
|
||||
const cwd = path.resolve('workspace', 'projectA');
|
||||
Storage.setRuntimeBaseDir(absolutePath, cwd);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('ignores cwd when path starts with tilde', () => {
|
||||
Storage.setRuntimeBaseDir(
|
||||
'~/runtime',
|
||||
path.resolve('workspace', 'projectA'),
|
||||
);
|
||||
const expected = path.join(os.homedir(), 'runtime');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('resolves relative paths in QWEN_RUNTIME_DIR env var', () => {
|
||||
process.env['QWEN_RUNTIME_DIR'] = 'relative/env-path';
|
||||
const expected = path.resolve('relative/env-path');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('resets to default when setRuntimeBaseDir is called with null', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(customDir);
|
||||
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
|
||||
it('resets to default when setRuntimeBaseDir is called with undefined', () => {
|
||||
Storage.setRuntimeBaseDir(path.resolve('custom'));
|
||||
Storage.setRuntimeBaseDir(undefined);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
|
||||
it('resets to default when setRuntimeBaseDir is called with empty string', () => {
|
||||
Storage.setRuntimeBaseDir(path.resolve('custom'));
|
||||
Storage.setRuntimeBaseDir('');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
|
||||
it('handles bare tilde (~) as home directory', () => {
|
||||
Storage.setRuntimeBaseDir('~');
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(os.homedir());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage – runtime path methods use getRuntimeBaseDir', () => {
|
||||
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
|
||||
|
||||
beforeEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
if (originalEnv !== undefined) {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
|
||||
} else {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
}
|
||||
});
|
||||
|
||||
it('getGlobalTempDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
expect(Storage.getGlobalTempDir()).toBe(path.join(customDir, 'tmp'));
|
||||
});
|
||||
|
||||
it('getGlobalDebugDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
expect(Storage.getGlobalDebugDir()).toBe(path.join(customDir, 'debug'));
|
||||
});
|
||||
|
||||
it('getDebugLogPath uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
expect(Storage.getDebugLogPath('session-123')).toBe(
|
||||
path.join(customDir, 'debug', 'session-123.txt'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getGlobalIdeDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
expect(Storage.getGlobalIdeDir()).toBe(path.join(customDir, 'ide'));
|
||||
});
|
||||
|
||||
it('getProjectDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
const storage = new Storage('/tmp/project');
|
||||
expect(storage.getProjectDir()).toContain(path.join(customDir, 'projects'));
|
||||
});
|
||||
|
||||
it('getHistoryDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
const storage = new Storage('/tmp/project');
|
||||
expect(storage.getHistoryDir()).toContain(path.join(customDir, 'history'));
|
||||
});
|
||||
|
||||
it('getProjectTempDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
const storage = new Storage('/tmp/project');
|
||||
expect(storage.getProjectTempDir()).toContain(path.join(customDir, 'tmp'));
|
||||
});
|
||||
|
||||
it('getProjectTempCheckpointsDir uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
const storage = new Storage('/tmp/project');
|
||||
expect(storage.getProjectTempCheckpointsDir()).toContain(
|
||||
path.join(customDir, 'tmp'),
|
||||
);
|
||||
expect(storage.getProjectTempCheckpointsDir()).toMatch(/checkpoints$/);
|
||||
});
|
||||
|
||||
it('getHistoryFilePath uses custom runtime base dir', () => {
|
||||
const customDir = path.resolve('custom');
|
||||
Storage.setRuntimeBaseDir(customDir);
|
||||
const storage = new Storage('/tmp/project');
|
||||
expect(storage.getHistoryFilePath()).toContain(path.join(customDir, 'tmp'));
|
||||
expect(storage.getHistoryFilePath()).toMatch(/shell_history$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage – config paths remain at ~/.qwen regardless of runtime dir', () => {
|
||||
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
|
||||
const globalQwenDir = Storage.getGlobalQwenDir();
|
||||
|
||||
beforeEach(() => {
|
||||
Storage.setRuntimeBaseDir(path.resolve('custom-runtime'));
|
||||
process.env['QWEN_RUNTIME_DIR'] = path.resolve('env-runtime');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
if (originalEnv !== undefined) {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
|
||||
} else {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
}
|
||||
});
|
||||
|
||||
it('getGlobalSettingsPath still uses ~/.qwen', () => {
|
||||
expect(Storage.getGlobalSettingsPath()).toBe(
|
||||
path.join(globalQwenDir, 'settings.json'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getInstallationIdPath still uses ~/.qwen', () => {
|
||||
expect(Storage.getInstallationIdPath()).toBe(
|
||||
path.join(globalQwenDir, 'installation_id'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getGoogleAccountsPath still uses ~/.qwen', () => {
|
||||
expect(Storage.getGoogleAccountsPath()).toBe(
|
||||
path.join(globalQwenDir, 'google_accounts.json'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getMcpOAuthTokensPath still uses ~/.qwen', () => {
|
||||
expect(Storage.getMcpOAuthTokensPath()).toBe(
|
||||
path.join(globalQwenDir, 'mcp-oauth-tokens.json'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getOAuthCredsPath still uses ~/.qwen', () => {
|
||||
expect(Storage.getOAuthCredsPath()).toBe(
|
||||
path.join(globalQwenDir, 'oauth_creds.json'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getUserCommandsDir still uses ~/.qwen', () => {
|
||||
expect(Storage.getUserCommandsDir()).toBe(
|
||||
path.join(globalQwenDir, 'commands'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getGlobalMemoryFilePath still uses ~/.qwen', () => {
|
||||
expect(Storage.getGlobalMemoryFilePath()).toBe(
|
||||
path.join(globalQwenDir, 'memory.md'),
|
||||
);
|
||||
});
|
||||
|
||||
it('getGlobalBinDir still uses ~/.qwen', () => {
|
||||
expect(Storage.getGlobalBinDir()).toBe(path.join(globalQwenDir, 'bin'));
|
||||
});
|
||||
|
||||
it('getUserSkillsDirs still includes ~/.qwen/skills', () => {
|
||||
const storage = new Storage('/tmp/project');
|
||||
const skillsDirs = storage.getUserSkillsDirs();
|
||||
expect(
|
||||
skillsDirs.some((dir) => dir === path.join(globalQwenDir, 'skills')),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage – runtime base dir async context isolation', () => {
|
||||
const originalEnv = process.env['QWEN_RUNTIME_DIR'];
|
||||
|
||||
beforeEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
if (originalEnv !== undefined) {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalEnv;
|
||||
} else {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
}
|
||||
});
|
||||
|
||||
it('uses contextual runtime dir inside runWithRuntimeBaseDir', async () => {
|
||||
Storage.setRuntimeBaseDir(path.resolve('global-runtime'));
|
||||
const cwd = path.resolve('workspace', 'project-a');
|
||||
|
||||
await Storage.runWithRuntimeBaseDir('.qwen', cwd, async () => {
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen'));
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps concurrent contexts isolated', async () => {
|
||||
const cwdA = path.resolve('workspace', 'a');
|
||||
const cwdB = path.resolve('workspace', 'b');
|
||||
|
||||
const runA = Storage.runWithRuntimeBaseDir('.qwen-a', cwdA, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return Storage.getRuntimeBaseDir();
|
||||
});
|
||||
|
||||
const runB = Storage.runWithRuntimeBaseDir('.qwen-b', cwdB, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
return Storage.getRuntimeBaseDir();
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([runA, runB]);
|
||||
expect(a).toBe(path.join(cwdA, '.qwen-a'));
|
||||
expect(b).toBe(path.join(cwdB, '.qwen-b'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { getProjectHash, sanitizeCwd } from '../utils/paths.js';
|
||||
|
||||
export const QWEN_DIR = '.qwen';
|
||||
|
|
@ -23,10 +24,99 @@ const ARENA_DIR_NAME = 'arena';
|
|||
export class Storage {
|
||||
private readonly targetDir: string;
|
||||
|
||||
/**
|
||||
* Custom runtime output base directory set via settings.
|
||||
* When null, falls back to getGlobalQwenDir().
|
||||
*/
|
||||
private static runtimeBaseDir: string | null = null;
|
||||
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<
|
||||
string | null
|
||||
>();
|
||||
|
||||
constructor(targetDir: string) {
|
||||
this.targetDir = targetDir;
|
||||
}
|
||||
|
||||
private static resolveRuntimeBaseDir(
|
||||
dir: string | null | undefined,
|
||||
cwd?: string,
|
||||
): string | null {
|
||||
if (!dir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let resolved = dir;
|
||||
if (
|
||||
resolved === '~' ||
|
||||
resolved.startsWith('~/') ||
|
||||
resolved.startsWith('~\\')
|
||||
) {
|
||||
const relativeSegments =
|
||||
resolved === '~'
|
||||
? []
|
||||
: resolved
|
||||
.slice(2)
|
||||
.split(/[/\\]+/)
|
||||
.filter(Boolean);
|
||||
resolved = path.join(os.homedir(), ...relativeSegments);
|
||||
}
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
resolved = cwd ? path.resolve(cwd, resolved) : path.resolve(resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the custom runtime output base directory.
|
||||
* Handles tilde (~) expansion and resolves relative paths to absolute.
|
||||
* Pass null/undefined/empty string to reset to default (getGlobalQwenDir()).
|
||||
* @param dir - The directory path, or null/undefined to reset
|
||||
* @param cwd - Base directory for resolving relative paths (defaults to process.cwd()).
|
||||
* Pass the project root so that relative values like ".qwen" resolve
|
||||
* per-project, enabling a single global config to work across all projects.
|
||||
*/
|
||||
static setRuntimeBaseDir(dir: string | null | undefined, cwd?: string): void {
|
||||
Storage.runtimeBaseDir = Storage.resolveRuntimeBaseDir(dir, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs function execution in an async context with a specific runtime output dir.
|
||||
* This is used to isolate runtime output paths between concurrent sessions.
|
||||
*/
|
||||
static runWithRuntimeBaseDir<T>(
|
||||
dir: string | null | undefined,
|
||||
cwd: string | undefined,
|
||||
fn: () => T,
|
||||
): T {
|
||||
const resolved = Storage.resolveRuntimeBaseDir(dir, cwd);
|
||||
return Storage.runtimeBaseDirContext.run(resolved, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base directory for all runtime output (temp files, debug logs,
|
||||
* session data, todos, insights, etc.).
|
||||
*
|
||||
* Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir()
|
||||
* @returns Absolute path to the runtime output base directory
|
||||
*/
|
||||
static getRuntimeBaseDir(): string {
|
||||
const envDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
if (envDir) {
|
||||
return (
|
||||
Storage.resolveRuntimeBaseDir(envDir) ?? Storage.getGlobalQwenDir()
|
||||
);
|
||||
}
|
||||
|
||||
const contextualDir = Storage.runtimeBaseDirContext.getStore();
|
||||
if (contextualDir !== undefined) {
|
||||
return contextualDir ?? Storage.getGlobalQwenDir();
|
||||
}
|
||||
if (Storage.runtimeBaseDir) {
|
||||
return Storage.runtimeBaseDir;
|
||||
}
|
||||
return Storage.getGlobalQwenDir();
|
||||
}
|
||||
|
||||
static getGlobalQwenDir(): string {
|
||||
const homeDir = os.homedir();
|
||||
if (!homeDir) {
|
||||
|
|
@ -60,11 +150,11 @@ export class Storage {
|
|||
}
|
||||
|
||||
static getGlobalTempDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
|
||||
return path.join(Storage.getRuntimeBaseDir(), TMP_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalDebugDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), DEBUG_DIR_NAME);
|
||||
return path.join(Storage.getRuntimeBaseDir(), DEBUG_DIR_NAME);
|
||||
}
|
||||
|
||||
static getDebugLogPath(sessionId: string): string {
|
||||
|
|
@ -72,7 +162,7 @@ export class Storage {
|
|||
}
|
||||
|
||||
static getGlobalIdeDir(): string {
|
||||
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
|
||||
return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalBinDir(): string {
|
||||
|
|
@ -89,7 +179,10 @@ export class Storage {
|
|||
|
||||
getProjectDir(): string {
|
||||
const projectId = sanitizeCwd(this.getProjectRoot());
|
||||
const projectsDir = path.join(Storage.getGlobalQwenDir(), PROJECT_DIR_NAME);
|
||||
const projectsDir = path.join(
|
||||
Storage.getRuntimeBaseDir(),
|
||||
PROJECT_DIR_NAME,
|
||||
);
|
||||
return path.join(projectsDir, projectId);
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +207,7 @@ export class Storage {
|
|||
|
||||
getHistoryDir(): string {
|
||||
const hash = getProjectHash(this.getProjectRoot());
|
||||
const historyDir = path.join(Storage.getGlobalQwenDir(), 'history');
|
||||
const historyDir = path.join(Storage.getRuntimeBaseDir(), 'history');
|
||||
const targetDir = path.join(historyDir, hash);
|
||||
return targetDir;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue